Compare commits

...

39 commits

Author SHA1 Message Date
Kaan Barmore-Genç 847b6eb1c5
Upload to ghcr.io, document the fact that it's there 2023-03-16 00:10:45 -04:00
Kaan Barmore-Genç 706251c4d8
Update to 1.8.0 2023-02-13 01:04:05 -05:00
Kaan Barmore-Genç d4dffb19e9
Update deps (#95) 2023-02-13 01:03:34 -05:00
Kaan Barmore-Genç 5c6b38f7b0
Do retry update after a failure & fix tests (#94)
* Do retry update after a failure & fix tests

* Fix formatting

* Fix clippy errors

* Add codecov ignore for ip_source files
2023-02-13 00:58:23 -05:00
jannikac 8413555d2f
Implement thiserror and improve output formatting (#91)
* implemented an error struct with all possible errors using thiserror

* Replaced die_with with ClientError enum derived with thiserror.
This enables prettier and more structured error handling than anyhow

* Added proper parsing of Gandi API Responses.
This also makes it possible to output prettier logs.

* improved error message

* anyhow is better for main fn because it outputs anyhow errors correctly
2023-02-10 22:08:54 -05:00
Kaan Barmore-Genç 5cdd7b9e83
Update readme file name 2023-02-01 23:58:47 -05:00
Kaan Barmore-Genç 1bb4c7af1c
update build script 2023-02-01 23:57:01 -05:00
Kaan Barmore-Genç b71a78118b
Release 1.7.0 2023-02-01 23:24:09 -05:00
Kaan Barmore-Genç 27a60d3ac2
Add new IP source "seeip" (#90) 2023-02-01 23:19:57 -05:00
Kaan Barmore-Genç 327b14a00a
Skip updating the IP address if it did not change (#88)
* Skip updating the IP address if it did not change

* Update readme
2023-02-01 02:00:09 -05:00
Kaan Barmore-Genç f8060fad42
Avoid multiple main versions & concurrently get ipv4 and ipv6 addresses (#87)
* Avoid having multiple versions of main run function

* Concurrently get ipv4 and ipv6 addresses
2023-02-01 00:21:02 -05:00
Kaan Barmore-Genç 7e7a9da65e
Switch back to die-exit (#86)
We had temporarily moved to die-exit-2, a forked release, but the
original project has made a release now!
2023-01-31 00:16:02 -05:00
Kaan Barmore-Genç e95cf42b69
Update dependencies (#85) 2023-01-31 00:07:02 -05:00
allcontributors[bot] 4ada0b7fb4
docs: add jannikac as a contributor for code (#73)
* docs: update README.md [skip ci]

* docs: create .all-contributorsrc [skip ci]

* Update README.md

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
Co-authored-by: Kaan Barmore-Genç <kaan@bgenc.net>
2022-12-18 10:35:09 -05:00
Kaan Barmore-Genç d52ca4b840
Rename readme 2022-12-18 10:29:08 -05:00
Kaan Barmore-Genç ed83f7dedc
Add contributors block 2022-12-18 10:25:00 -05:00
Kaan Barmore-Genç f94222f048
Release 1.6.0 2022-12-18 01:41:47 -05:00
Kaan Barmore-Genç e2a343c59e
Update dependencies 2022-12-18 01:41:14 -05:00
Kaan Barmore-Genç 220e368bf3
Use async sleep & document the repeat option (#70)
* Use async sleep when long running

Using an async sleep just seems more appropriate as we are in an async
context, although the results are identical since gandi-live-dns only
uses a single thread.

* Document repeat option

* Fix lints
2022-12-18 01:35:54 -05:00
jannikac 041c1109e0
Fix linter suggestions (#68)
* removed unneeded lifetimes

* removed unneccessary imports

* removed unneccessary return statements

* added derive eq -> linter suggestion

* removed unneccessary references

* made code more concise

* ok_or_else instead of ok_or improves performance -> linter suggestion
2022-12-18 00:53:48 -05:00
jannikac bbd7ce347a
Add repeat flag (#67)
* Add a config option for the repeat flag

* Implement repeat flag
2022-12-18 00:53:34 -05:00
Kaan Barmore-Genç 28984e1b52
Fix badges 2022-12-18 00:30:40 -05:00
Kaan Barmore-Genç 35d60f0b29
read version correctly in release script 2022-11-13 16:49:03 -05:00
Kaan Barmore-Genç 7cffca51af
Add instructions for source builds 2022-11-13 16:21:27 -05:00
Kaan Barmore-Genç 1250e512f9
Update to forked die-exit to this can be published on crates.io 2022-11-13 16:18:32 -05:00
Kaan Barmore-Genç 985ce8ea5c
release 1.5.0 2022-11-13 15:53:56 -05:00
Kaan Barmore-Genç 984449f748
Correct branch name in action filter 2022-11-13 15:47:02 -05:00
Kaan Barmore-Genç 039d8933ad
Update dependencies (#59)
* Update dependencies

* Make sure tests run on main
2022-11-13 15:46:20 -05:00
Kaan Barmore-Genç 5755aedc2f
Add CLI option to skip ipv4 or ipv6 (#58)
closes #7
2022-11-13 15:36:21 -05:00
Kaan Barmore-Genç 84bef554b0
ignore signature files 2022-08-23 01:32:57 -04:00
Kaan Barmore-Genç 3daee43540
1.4.0 2022-08-23 01:21:28 -04:00
Kaan Barmore-Genç e91a9c5c4f
Update deps 2022-08-23 01:20:30 -04:00
Kaan Barmore-Genç eaabec35b4
Add option to use icanhazip as an IP source (#40)
* Add option to use icanhazip as an IP source

* skip IP source API tests in CI
2022-08-23 01:17:17 -04:00
Kaan Barmore-Genç 98e2931493
Update shields 2022-08-22 23:47:51 -04:00
Kaan Barmore-Genç 90c5485cce
Add first test (#38)
* wip

* wip implement test

* complete test

* add lint and test actions

* fix bad format
2022-08-22 22:20:10 -04:00
Kaan Barmore-Genç 80d8c8885b
Add shields 2022-07-21 21:23:34 -04:00
Kaan Barmore-Genç 49154e5f2f
release 1.3.0 2022-07-20 23:19:16 -04:00
Kaan Barmore-Genç 21dcd000a7
Report record types
closes #2
2022-07-20 23:18:37 -04:00
Kaan Barmore-Genç e556c00901
update dependency versions 2022-07-20 23:14:33 -04:00
23 changed files with 2482 additions and 503 deletions

25
.all-contributorsrc Normal file
View file

@ -0,0 +1,25 @@
{
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"commitConvention": "angular",
"contributors": [
{
"login": "jannikac",
"name": "jannikac",
"avatar_url": "https://avatars.githubusercontent.com/u/21014142?v=4",
"profile": "https://github.com/jannikac",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
"skipCi": true,
"repoType": "github",
"repoHost": "https://github.com",
"projectName": "gandi-live-dns-rust",
"projectOwner": "SeriousBug"
}

3
.clippy.toml Normal file
View file

@ -0,0 +1,3 @@
# assert_eq!(..., true) or false is a lot clearer when testing functions that
# return booleans.
bool_assert_comparison = false

4
.codecov.yml Normal file
View file

@ -0,0 +1,4 @@
ignore:
# These are tested, but the tests hit external services which is not
# necessarily smart to do in CI, so they get skipped.
- "src/ip_source"

17
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,17 @@
on: push
name: lint checks
jobs:
rust_lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: rustup component add clippy
- uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features
rust_format_check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: cargo fmt --all --check

25
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: test
on:
push:
branches:
- master
pull_request:
branches:
- "*"
jobs:
test:
name: run tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Rust
run: rustup toolchain install stable --component llvm-tools-preview
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate code coverage
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: lcov.info
fail_ci_if_error: true

3
.gitignore vendored
View file

@ -1,4 +1,5 @@
/target
gandi.toml
*.tar*
*.zip
*.zip
*.sig

View file

@ -3,5 +3,6 @@
"gandi",
"rrset",
"structopt"
]
],
"editor.formatOnSave": true
}

1465
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,45 @@
[package]
name = "gandi-live-dns"
version = "1.2.1"
description = "Automatically updates your IP address in Gandi's Live DNS. Makes it possible to use Gandi as a dynamic DNS system."
version = "1.8.0"
edition = "2021"
authors = ["Kaan Barmore-Genç <kaan@bgenc.net>"]
license = "MIT"
readme = "README.md"
repository = "https://github.com/SeriousBug/gandi-live-dns-rust"
[profile.release]
strip = "symbols"
lto = true
[dependencies]
reqwest = { version = "0.11", default-features= false, features = ["json", "rustls-tls"] }
toml = "0.5"
reqwest = { version = "0.11", default-features = false, features = [
"json",
"rustls-tls",
] }
toml = "0.7"
json = "0.12"
serde = { version = "1.0", features = ["derive"] }
directories = "4.0"
clap = { version = "3.1", features = ["derive", "cargo", "unicode", "wrap_help"]}
tokio = { version = "1.19", features = ["full"] }
clap = { version = "4.0", features = [
"derive",
"cargo",
"unicode",
"wrap_help",
] }
tokio = { version = "1.23", features = ["full"] }
futures = "0.3"
anyhow = "1.0"
governor = "0.4"
# TODO: Relies on a yet-unreleased interface. Switch to an actual crate release once available
die-exit = { git = "https://github.com/Xavientois/die.git", rev = "31d3801f4e21654b0b28430987b1e21fc7728676" }
governor = "0.5"
async-trait = "0.1"
die-exit = "0.4"
thiserror = "1.0.38"
[dev-dependencies]
httpmock = "0.6"
regex = "1.6"
lazy_static = "1.4.0"
[dev-dependencies.die-exit]
version = "0.4"
features = ["test"]

203
README.md Normal file
View file

@ -0,0 +1,203 @@
## Gandi Live Dns Rust <!-- omit in toc -->
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?label=contributors)](#contributors) <!-- ALL-CONTRIBUTORS-BADGE:END -->
[![tests](https://img.shields.io/github/actions/workflow/status/SeriousBug/gandi-live-dns-rust/test.yml?label=tests&branch=master)](https://github.com/SeriousBug/gandi-live-dns-rust/actions/workflows/test.yml)
[![Test coverage report](https://img.shields.io/codecov/c/github/SeriousBug/gandi-live-dns-rust)](https://codecov.io/gh/SeriousBug/gandi-live-dns-rust)
[![lint checks](https://img.shields.io/github/actions/workflow/status/SeriousBug/gandi-live-dns-rust/lint.yml?label=lints&branch=master)](https://github.com/SeriousBug/gandi-live-dns-rust/actions/workflows/lint.yml)
[![Releases](https://img.shields.io/github/v/release/SeriousBug/gandi-live-dns-rust?include_prereleases)](https://github.com/SeriousBug/gandi-live-dns-rust/releases)
[![Docker Image Size](https://img.shields.io/docker/image-size/seriousbug/gandi-live-dns-rust)](https://hub.docker.com/r/seriousbug/gandi-live-dns-rust)
[![MIT license](https://img.shields.io/github/license/SeriousBug/gandi-live-dns-rust)](https://github.com/SeriousBug/gandi-live-dns-rust/blob/master/LICENSE.txt)
A program that can set the IP addresses for configured DNS entries in
[Gandi](https://gandi.net)'s domain configuration. Thanks to Gandi's
[LiveDNS API](https://api.gandi.net/docs/livedns/),
this creates a dynamic DNS system.
If you want to host web services but you don't have a static IP address, this
tool will allow you to keep your domains pointed at the right IP address. This
program can update both IPv4 and IPv6 addresses for one or more domains and
subdomains. It can be used as a one-shot tool managed with a systemd timer
or cron, or a long-running process that reschedules itself.
## Table of Contents <!-- omit in toc -->
- [Usage](#usage)
- [System packages](#system-packages)
- [Prebuilt binaries](#prebuilt-binaries)
- [With docker](#with-docker)
- [From source](#from-source)
- [Automation](#automation)
- [By running as a background process](#by-running-as-a-background-process)
- [Skipped updates](#skipped-updates)
- [With a Systemd timer](#with-a-systemd-timer)
- [Development](#development)
- [Local builds](#local-builds)
- [Making a release](#making-a-release)
- [Alternatives](#alternatives)
- [Contributors](#contributors)
## Usage
The Gandi Live DNS API is rate limited at 30 requests per minute. This program
respects this rate limit: if you have more than 30 domains to update, the
program will pause and wait for a minute, plus a random delay to ensure it
doesn't hit the rate limit.
### System packages
Packages are available for some linux distributions.
- ArchLinux: [gandi-live-dns-rust on AUR](https://aur.archlinux.org/packages/gandi-live-dns-rust/)
> Contributions to release this for other distributions are welcome!
### Prebuilt binaries
`gandi-live-dns` provides pre-built binaries with the releases. See the
[releases page](https://github.com/SeriousBug/gandi-live-dns-rust/releases) to
get the latest version. These binaries are statically linked, and provided for
both Linux and Windows, including ARM architectures for the Linux version.
Download the latest version from the releases page, extract it from the archive, and place it somewhere in your `$PATH` to use it.
- Create a file `gandi.toml`, then copy and paste the contents of [`example.toml`](https://raw.githubusercontent.com/SeriousBug/gandi-live-dns-rust/master/example.toml)
- Follow the instructions in the example config to get your API key and put it in the config
- Follow the examples in the config to set up the entries you want to update
- Run `gandi-live-dns` inside the directory with the configration to update your DNS entries
### With docker
Container images are available on both Github Packages and Docker Hub.
- [ghcr.io/seriousbug/gandi-live-dns-rust](https://github.com/users/seriousbug/packages/container/package/gandi-live-dns-rust)
- [docker.io/seriousbug/gandi-live-dns-rust](https://hub.docker.com/r/seriousbug/gandi-live-dns-rust)
The container images are built multi-arch, with support for x86_64, arm64,
armv7, and armv6 platforms. Follow the steps below to use them. You can use
`seriousbug/gandi-live-dns-rust` directly which will default to Docker Hub,
otherwise add `ghcr.io` in the examples below to use Github Packages.
- Create a file `gandi.toml`, then copy and paste the contents of [`example.toml`](https://raw.githubusercontent.com/SeriousBug/gandi-live-dns-rust/master/example.toml)
- Follow the instructions in the example config to get your API key and put it in the config
- Follow the examples in the config to set up the entries you want to update
- Run `docker run --rm -it -v $(pwd)/gandi.toml:/gandi.toml:ro seriousbug/gandi-live-dns-rust:latest`
> Docker doesn't [support IPv6](https://docs.docker.com/config/daemon/ipv6/) out
> of the box. If you need to update IPv6 addresses, check the linked page to enable IPv6 or use the prebuilt binaries directly.
> If you get [errors](https://stackoverflow.com/questions/42248198/how-to-mount-a-single-file-in-a-volume) about not finding the config file, make sure your command
> has a full path to the config file (`$(pwd)/gandi.toml` part). Otherwise
> Docker will create a directory.
### From source
This package is also published on `crates.io` as
[gandi-live-dns](https://crates.io/crates/gandi-live-dns). If you would like to
build it from source and you have a working rust install, you can use `cargo install gandi-live-dns` to build and install it.
## Automation
### By running as a background process
`gandi-live-dns` can run as a daemon, a background process, periodically perform
the IP address updates. To do so, add the `--repeat=<delay-in-seconds>` command
line option. When given, this tool will not quit after updating your IP address
and instead will continue to perform periodic updates.
If you are using Docker, you can add this option when starting it:
```bash
# This will update your IP now, then repeat every 24 hours
docker run --rm -it -v $(pwd)/gandi.toml:/gandi.toml:ro seriousbug/gandi-live-dns-rust:latest --repeat=86400
```
Or with a `docker-compose.yml` file, add it in the arguments:
```yml
gandi-live-dns:
image: seriousbug/gandi-live-dns-rust:latest
restart: always
volumes:
- ./gandi.toml:/gandi.toml:ro
# Repeat the update every day
command: --repeat=86400
```
#### Skipped updates
In background process mode, the tool will avoid sending an update to Gandi if
your IP address has not changed since the last update. This only works so long
as the tool continues to run, it will send an update when restarted even if your
IP address has not changed. You can also override this behavior by adding
`always_update = true` to the top of your config file.
### With a Systemd timer
The `Packaging` folder contains a Systemd service and timer, which you can use
to automatically run this tool. By default it will update the IP addresses after
every boot up, and at least once a day. You can adjust the timer to speed this
up, but avoid unnecessarily overloading Gandi's servers.
- Place `gandi-live-dns.timer` and `gandi-live-dns.service` into `/etc/systemd/system`
- Put `gandi-live-dns` binary into `/usr/bin/`
- You can also place it in `/usr/local/bin` or some other directory, just make sure to update the path in the service file
- Create the folder `/etc/gandi-live-dns`, and place your `gandi.toml` into it
- Create a user for the service: `useradd --system gandi-live-dns --home-dir /etc/gandi-live-dns`
- Make sure only the service can access the config file: `chown gandi-live-dns: /etc/gandi-live-dns/gandi.toml && chmod 600 /etc/gandi-live-dns/gandi.toml`
- Enable the timer with `systemctl enable --now gandi-live-dns.timer`
## Development
### Local builds
`cargo build` and `cargo build --release` are sufficient for development and release builds.
No special instructions are needed.
### Making a release
To make a release, first set up `cross` and `docker`. Make sure you log into
Docker with `docker login`. Then follow these steps:
- bump up the version in `Cargo.toml` according to [semver](https://semver.org/)
- commit and push the changes
- run `./make-release.sh`
> This will build binaries, then package them into archives, as well as
> build and upload docker images.
- Create a release on Github
- Make sure to create a tag for the release version on `master`
- Upload the binary archives to the Github release
- Update the AUR version
- Run `cargo publish` to update the crates.io version
## Alternatives
- [laur89's Bash based updater](https://github.com/laur89/docker-gandi-dns-update)
- [ Adam Vigneaux's Bash based updater, with a docker image](https://github.com/AdamVig/gandi-dynamic-dns)
- [Yago Riveiro's Python based updater](https://github.com/yriveiro/giu)
- [ Maxime Le Conte des Floris' Go based updater](https://github.com/mlcdf/dyndns)
## Contributors
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tbody>
<tr>
<td align="center"><a href="https://github.com/jannikac"><img src="https://avatars.githubusercontent.com/u/21014142?v=4?s=100" width="100px;" alt="jannikac"/><br /><sub><b>jannikac</b></sub></a><br /><a href="https://github.com/SeriousBug/gandi-live-dns-rust/commits?author=jannikac" title="Code">💻</a></td>
</tr>
</tbody>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->

102
Readme.md
View file

@ -1,102 +0,0 @@
## gandi-live-dns-rust
A program that can set the IP addresses for configured DNS entries in
[Gandi](https://gandi.net)'s domain configuration. Thanks to Gandi's
[LiveDNS API](https://api.gandi.net/docs/livedns/),
this creates a dynamic DNS system.
If you want to host web services but you don't have a static IP address, this
tool will allow you to keep your domains pointed at the right IP address. This
program can update both IPv4 and IPv6 addresses for one or more domains and
subdomains. It's a one-shot tool that's meant to be managed with a systemd timer
or cron.
## Usage
The Gandi Live DNS API is rate limited at 30 requests per minute. This program
respects this rate limit: if you have more than 30 domains to update, the
program will pause and wait for a minute, plus a random delay to ensure it
doesn't hit the rate limit.
### System packages
Packages are available for some linux distributions.
- ArchLinux: [gandi-live-dns-rust on AUR](https://aur.archlinux.org/packages/gandi-live-dns-rust/)
> Contributions to release this for other distributions are welcome!
### Prebuilt binaries
`gandi-live-dns` provides pre-built binaries with the releases. See the
[releases page](https://github.com/SeriousBug/gandi-live-dns-rust/releases) to
get the latest version. These binaries are statically linked, and provided for
both Linux and Windows, including ARM architectures for the Linux version.
Download the latest version from the releases page, extract it from the archive, and place it somewhere in your `$PATH` to use it.
- Create a file `gandi.toml`, then copy and paste the contents of [`example.toml`](https://raw.githubusercontent.com/SeriousBug/gandi-live-dns-rust/master/example.toml)
- Follow the instructions in the example config to get your API key and put it in the config
- Follow the examples in the config to set up the entries you want to update
- Run `gandi-live-dns` inside the directory with the configration to update your DNS entries
### With docker
Use the [seriousbug/gandi-live-dns-rust](https://hub.docker.com/r/seriousbug/gandi-live-dns-rust) Docker images, which are available for x86_64,
arm64, armv6, and armv7 platforms. Follow the steps below to use these images.
- Create a file `gandi.toml`, then copy and paste the contents of [`example.toml`](https://raw.githubusercontent.com/SeriousBug/gandi-live-dns-rust/master/example.toml)
- Follow the instructions in the example config to get your API key and put it in the config
- Follow the examples in the config to set up the entries you want to update
- Run `docker run --rm -it -v $(pwd)/gandi.toml:/gandi.toml:ro seriousbug/gandi-live-dns-rust:latest`
> Docker doesn't [support IPv6](https://docs.docker.com/config/daemon/ipv6/) out
> of the box. If you need to update IPv6 addresses, check the linked page to enable IPv6 or use the prebuilt binaries directly.
> If you get [errors](https://stackoverflow.com/questions/42248198/how-to-mount-a-single-file-in-a-volume) about not finding the config file, make sure your command
> has a full path to the config file (`$(pwd)/gandi.toml` part). Otherwise
> Docker will create a directory.
## Automation
The `Packaging` folder contains a Systemd service and timer, which you can use
to automatically run this tool. By default it will update the IP addresses after
every boot up, and at least once a day. You can adjust the timer to speed this
up, but avoid unnecessarily overloading Gandi's servers.
- Place `gandi-live-dns.timer` and `gandi-live-dns.service` into `/etc/systemd/system`
- Put `gandi-live-dns` binary into `/usr/bin/`
- You can also place it in `/usr/local/bin` or some other directory, just make sure to update the path in the service file
- Create the folder `/etc/gandi-live-dns`, and place your `gandi.toml` into it
- Create a user for the service: `useradd --system gandi-live-dns --home-dir /etc/gandi-live-dns`
- Make sure only the service can access the config file: `chown gandi-live-dns: /etc/gandi-live-dns/gandi.toml && chmod 600 /etc/gandi-live-dns/gandi.toml`
- Enable the timer with `systemctl enable --now gandi-live-dns.timer`
## Development
### Local builds
`cargo build` and `cargo build --release` are sufficient for development and release builds.
No special instructions are needed.
### Making a release
To make a release, first set up `cross` and `docker`. Make sure you log into
Docker with `docker login`. Then follow these steps:
- bump up the version in `Cargo.toml` according to [semver](https://semver.org/)
- commit and push the changes
- run `./make-release.sh`
> This will build binaries, then package them into archives, as well as
> build and upload docker images.
- Create a release on Github
- Make sure to create a tag for the release version on `master`
- Upload the binary archives to the Github release
- Update the AUR version manually
## Alternatives
- [laur89's Bash based updater](https://github.com/laur89/docker-gandi-dns-update)
- [ Adam Vigneaux's Bash based updater, with a docker image](https://github.com/AdamVig/gandi-dynamic-dns)
- [Yago Riveiro's Python based updater](https://github.com/yriveiro/giu)
- [ Maxime Le Conte des Floris' Go based updater](https://github.com/mlcdf/dyndns)

View file

@ -15,6 +15,14 @@ api_key = "xxxxxxxxxxxxxxxxxxxxxxxx"
# your IP address propagate quickly.
ttl = 300
# Where to query your IP address from. These options are free and unlimited.
# Ipify is used by default. If you want to change it, uncomment the one you want
# to use.
#
#ip_source = "Ipify" # An open source and public service. https://github.com/rdegges/ipify-api
#ip_source = "Icanhazip" # A free service, currently run by Cloudflare. https://major.io/2021/06/06/a-new-future-for-icanhazip/
#ip_source = "SeeIP" # A free service, run by UNVIO, LLC. https://seeip.org/
# For every domain or subdomain you want to update, create an entry below.
[[entry]]

View file

@ -3,9 +3,7 @@
# Make sure `cross` is installed.
# You'll also need `sed`, a relatively recent version of `tar`, and `7z`.
#
# This script runs does `sudo docker` to build and push the release to docker.
# If you have rootless docker set up, remove sudo from this variable.
DOCKER="sudo docker"
DOCKER="docker"
#
shopt -s extglob
# Trap errors and interrupts
@ -44,25 +42,27 @@ declare -A DOCKER_TARGETS=(
)
# Get the version number
VERSION=$(sed -nr 's/^version *= *"([0-9.]+)"/\1/p' Cargo.toml)
VERSION=$(sed -nr 's/^version *= *"([0-9.]+)"/\1/p' Cargo.toml | head --lines=1)
# Make the builds
for target in "${!TARGETS[@]}"; do
echo Building "${target}"
# Keeping the cached builds seem to be breaking things when going between targets
# This wouldn't be a problem if these were running in a matrix on the CI...
rm -rf target/release/
cross build -j $(($(nproc) / 2)) --release --target "${target}"
if [[ "${target}" =~ .*"windows".* ]] ; then
if [[ "${target}" =~ .*"windows".* ]]; then
zip -j "gandi-live-dns.${VERSION}.${TARGETS[${target}]}.zip" target/"${target}"/release/gandi-live-dns.exe 1>/dev/null
else
tar -acf "gandi-live-dns.${VERSION}.${TARGETS[${target}]}.tar.xz" -C "target/${target}/release/" "gandi-live-dns"
fi
done
if [[ "$#" -ge 2 && "$1" = "--no-docker" ]] ; then
if [[ "$#" -ge 2 && "$1" = "--no-docker" ]]; then
echo "Exiting without releasing to docker"
exit 0
fi
# Copy files into place so Docker can get them easily
cd Docker
echo Building Docker images
@ -77,4 +77,6 @@ ${DOCKER} buildx build . \
--file "Dockerfile" \
--tag "seriousbug/gandi-live-dns-rust:latest" \
--tag "seriousbug/gandi-live-dns-rust:${VERSION}" \
--tag "ghcr.io/seriousbug/gandi-live-dns-rust:latest" \
--tag "ghcr.io/seriousbug/gandi-live-dns-rust:${VERSION}" \
--push

View file

@ -1,30 +1,68 @@
use crate::opts;
use anyhow;
use directories::ProjectDirs;
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::{fs, io};
use thiserror::Error;
fn default_types() -> Vec<String> {
DEFAULT_TYPES.iter().map(|v| v.to_string()).collect()
}
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("Failed to read config file: {0} ")]
Io(#[from] io::Error),
#[error("Failed to parse config file: {0}")]
Parse(#[from] toml::de::Error),
#[error("Entry '{0}' has invalid type '{1}'")]
Validation(String, String),
#[error("Can't find config directory")]
ConfigNotFound(),
}
#[derive(Deserialize, Debug)]
pub struct Entry {
pub name: String,
types: Option<Vec<String>>,
#[serde(default = "default_types")]
types: Vec<String>,
fqdn: Option<String>,
ttl: Option<u32>,
}
fn default_ttl() -> u32 { return 300; }
fn default_ttl() -> u32 {
300
}
#[derive(Deserialize, Debug, PartialEq, Eq)]
pub enum IPSourceName {
Ipify,
Icanhazip,
SeeIP,
}
impl Default for IPSourceName {
fn default() -> Self {
// Ipify was the first IP source gandi-live-dns had, before it supported
// multiple sources. Keeping that as the default.
Self::Ipify
}
}
#[derive(Deserialize, Debug)]
pub struct Config {
fqdn: String,
pub api_key: String,
#[serde(default)]
pub ip_source: IPSourceName,
pub entry: Vec<Entry>,
#[serde(default = "default_ttl")]
pub ttl: u32,
#[serde(default)]
pub always_update: bool,
}
const DEFAULT_TYPES: &'static [&'static str] = &["A"];
const DEFAULT_TYPES: &[&str] = &["A"];
impl Config {
pub fn fqdn<'c>(entry: &'c Entry, config: &'c Config) -> &'c str {
@ -35,27 +73,25 @@ impl Config {
entry.ttl.unwrap_or(config.ttl)
}
pub fn types<'e>(entry: &'e Entry) -> Vec<&'e str> {
entry
.types
.as_ref()
.and_then(|ts| Some(ts.iter().map(|t| t.as_str()).collect()))
.unwrap_or_else(|| DEFAULT_TYPES.to_vec())
pub fn types(entry: &Entry) -> Vec<&str> {
entry.types.iter().map(|t| t.as_str()).collect()
}
}
fn load_config_from<P: std::convert::AsRef<std::path::Path>>(path: P) -> anyhow::Result<Config> {
fn load_config_from<P: std::convert::AsRef<std::path::Path>>(
path: P,
) -> Result<Config, ConfigError> {
let contents = fs::read_to_string(path)?;
Ok(toml::from_str(&contents)?)
}
pub fn load_config(opts: &opts::Opts) -> anyhow::Result<Config> {
match &opts.config {
Some(config_path) => load_config_from(&config_path),
pub fn load_config(opts: &opts::Opts) -> Result<Config, ConfigError> {
let mut config = match &opts.config {
Some(config_path) => load_config_from(config_path),
None => {
let confpath = ProjectDirs::from("me", "kaangenc", "gandi-dynamic-dns")
.and_then(|dir| Some(PathBuf::from(dir.config_dir()).join("config.toml")))
.ok_or(anyhow::anyhow!("Can't find config directory"));
.map(|dir| PathBuf::from(dir.config_dir()).join("config.toml"))
.ok_or(ConfigError::ConfigNotFound());
confpath
.and_then(|path| {
println!("Checking for config: {}", path.to_string_lossy());
@ -67,16 +103,195 @@ pub fn load_config(opts: &opts::Opts) -> anyhow::Result<Config> {
load_config_from(path)
})
}
}?;
// Filter out any types skipped in CLI opts
if opts.skip_ipv4 || opts.skip_ipv6 {
config.entry = config
.entry
.into_iter()
.map(|mut entry| {
entry
.types
.retain(|v| (v == "A" && !opts.skip_ipv4) || (v == "AAAA" && !opts.skip_ipv6));
entry
})
.collect();
}
Ok(config)
}
pub fn validate_config(config: &Config) -> anyhow::Result<()> {
pub fn validate_config(config: &Config) -> Result<(), ConfigError> {
for entry in &config.entry {
for entry_type in Config::types(&entry) {
for entry_type in Config::types(entry) {
if entry_type != "A" && entry_type != "AAAA" {
anyhow::bail!("Entry {} has invalid type {}", entry.name, entry_type);
return Err(ConfigError::Validation(
entry.name.clone(),
entry_type.to_string(),
));
}
}
}
return Ok(());
Ok(())
}
#[cfg(test)]
mod tests {
use super::load_config;
use crate::{config::IPSourceName, opts::Opts};
use std::{env::temp_dir, fs};
#[test]
fn load_config_test() {
let mut temp = temp_dir().join("gandi-live-dns-test");
fs::create_dir_all(&temp).expect("Failed to create test dir");
temp.push("test-1.toml");
fs::write(
&temp,
r#"
fqdn = "example.com"
api_key = "xxx"
ttl = 300
[[entry]]
name = "www"
[[entry]]
name = "@"
"#,
)
.expect("Failed to write test config file");
let opts = Opts {
config: Some(temp.to_string_lossy().to_string()),
..Opts::default()
};
let conf = load_config(&opts).expect("Failed to load config file");
assert_eq!(conf.fqdn, "example.com");
assert_eq!(conf.api_key, "xxx");
assert_eq!(conf.ttl, 300);
assert_eq!(conf.entry.len(), 2);
assert_eq!(conf.entry[0].name, "www");
assert_eq!(conf.entry[0].types, vec!["A".to_string()]);
assert_eq!(conf.entry[1].name, "@");
assert_eq!(conf.entry[1].types, vec!["A".to_string()]);
// default
assert_eq!(conf.ip_source, IPSourceName::Ipify);
assert_eq!(conf.always_update, false);
}
#[test]
fn load_config_change_ip_source() {
let mut temp = temp_dir().join("gandi-live-dns-test");
fs::create_dir_all(&temp).expect("Failed to create test dir");
temp.push("test-2.toml");
fs::write(
&temp,
r#"
fqdn = "example.com"
api_key = "yyy"
ttl = 1200
ip_source = "Icanhazip"
always_update = true
[[entry]]
name = "www"
[[entry]]
name = "@"
"#,
)
.expect("Failed to write test config file");
let opts = Opts {
config: Some(temp.to_string_lossy().to_string()),
..Opts::default()
};
let conf = load_config(&opts).expect("Failed to load config file");
assert_eq!(conf.fqdn, "example.com");
assert_eq!(conf.api_key, "yyy");
assert_eq!(conf.ttl, 1200);
assert_eq!(conf.entry.len(), 2);
assert_eq!(conf.entry[0].name, "www");
assert_eq!(conf.entry[1].name, "@");
assert_eq!(conf.ip_source, IPSourceName::Icanhazip);
assert_eq!(conf.always_update, true);
}
#[test]
fn load_config_skip_ipv4_with_opts() {
let mut temp = temp_dir().join("gandi-live-dns-test");
fs::create_dir_all(&temp).expect("Failed to create test dir");
temp.push("test-3.toml");
fs::write(
&temp,
r#"
fqdn = "example.com"
api_key = "yyy"
[[entry]]
name = "www"
types = ["A", "AAAA"]
[[entry]]
name = "@"
types = ["A", "AAAA"]
"#,
)
.expect("Failed to write test config file");
let opts = Opts {
config: Some(temp.to_string_lossy().to_string()),
skip_ipv4: true,
..Opts::default()
};
let conf = load_config(&opts).expect("Failed to load config file");
assert_eq!(conf.fqdn, "example.com");
assert_eq!(conf.api_key, "yyy");
assert_eq!(conf.entry.len(), 2);
assert_eq!(conf.entry[0].name, "www");
assert_eq!(conf.entry[0].types, vec!["AAAA".to_string()]);
assert_eq!(conf.entry[1].name, "@");
assert_eq!(conf.entry[1].types, vec!["AAAA".to_string()]);
}
#[test]
fn load_config_skip_ipv6_with_opts() {
let mut temp = temp_dir().join("gandi-live-dns-test");
fs::create_dir_all(&temp).expect("Failed to create test dir");
temp.push("test-4.toml");
fs::write(
&temp,
r#"
fqdn = "example.com"
api_key = "yyy"
[[entry]]
name = "www"
types = ["A", "AAAA"]
[[entry]]
name = "@"
types = ["A", "AAAA"]
"#,
)
.expect("Failed to write test config file");
let opts = Opts {
config: Some(temp.to_string_lossy().to_string()),
skip_ipv6: true,
..Opts::default()
};
let conf = load_config(&opts).expect("Failed to load config file");
assert_eq!(conf.fqdn, "example.com");
assert_eq!(conf.api_key, "yyy");
assert_eq!(conf.entry.len(), 2);
assert_eq!(conf.entry[0].name, "www");
assert_eq!(conf.entry[0].types, vec!["A".to_string()]);
assert_eq!(conf.entry[1].name, "@");
assert_eq!(conf.entry[1].types, vec!["A".to_string()]);
}
}

15
src/gandi/mod.rs Normal file
View file

@ -0,0 +1,15 @@
pub(crate) struct GandiAPI<'t> {
pub(crate) base_url: &'t str,
pub(crate) fqdn: &'t str,
pub(crate) rrset_name: &'t str,
pub(crate) rrset_type: &'t str,
}
impl<'t> GandiAPI<'t> {
pub(crate) fn url(&self) -> String {
format!(
"{}/v5/livedns/domains/{}/records/{}/{}",
self.base_url, self.fqdn, self.rrset_name, self.rrset_type
)
}
}

6
src/ip_source/Readme.md Normal file
View file

@ -0,0 +1,6 @@
The IP sources. These are APIs that we can query to get the IP address of the
current service.
The tests under this directory are all marked to be skipped, the tests hit the
actual APIs and can be flaky in CI. Make sure to run the tests manually if you
have to modify the code.

9
src/ip_source/common.rs Normal file
View file

@ -0,0 +1,9 @@
use async_trait::async_trait;
use crate::ClientError;
#[async_trait]
pub trait IPSource {
async fn get_ipv4(&self) -> Result<String, ClientError>;
async fn get_ipv6(&self) -> Result<String, ClientError>;
}

View file

@ -0,0 +1,64 @@
use async_trait::async_trait;
use crate::ClientError;
use super::common::IPSource;
pub(crate) struct IPSourceIcanhazip;
async fn get_ip(api_url: &str) -> Result<String, ClientError> {
let response = reqwest::get(api_url).await?;
let text = response.text().await?;
Ok(text)
}
#[async_trait]
impl IPSource for IPSourceIcanhazip {
async fn get_ipv4(&self) -> Result<String, ClientError> {
Ok(get_ip("https://ipv4.icanhazip.com")
.await?
// icanazip puts a newline at the end
.trim()
.to_string())
}
async fn get_ipv6(&self) -> Result<String, ClientError> {
Ok(get_ip("https://ipv6.icanhazip.com")
.await?
// icanazip puts a newline at the end
.trim()
.to_string())
}
}
#[cfg(test)]
mod tests {
use regex::Regex;
use crate::ip_source::common::IPSource;
use super::IPSourceIcanhazip;
#[tokio::test]
#[ignore]
async fn ipv4_test() {
let ipv4 = IPSourceIcanhazip
.get_ipv4()
.await
.expect("Failed to get the IP address");
assert!(Regex::new(r"^\d+[.]\d+[.]\d+[.]\d+$")
.unwrap()
.is_match(ipv4.as_str()))
}
#[tokio::test]
#[ignore]
async fn ipv6_test() {
let ipv6 = IPSourceIcanhazip
.get_ipv6()
.await
.expect("Failed to get the IP address");
assert!(Regex::new(r"^([0-9a-fA-F]*:){7}[0-9a-fA-F]*$")
.unwrap()
.is_match(ipv6.as_str()))
}
}

55
src/ip_source/ipify.rs Normal file
View file

@ -0,0 +1,55 @@
use async_trait::async_trait;
use crate::ClientError;
use super::common::IPSource;
pub(crate) struct IPSourceIpify;
async fn get_ip(api_url: &str) -> Result<String, ClientError> {
let response = reqwest::get(api_url).await?;
let text = response.text().await?;
Ok(text)
}
#[async_trait]
impl IPSource for IPSourceIpify {
async fn get_ipv4(&self) -> Result<String, ClientError> {
get_ip("https://api.ipify.org").await
}
async fn get_ipv6(&self) -> Result<String, ClientError> {
get_ip("https://api6.ipify.org").await
}
}
#[cfg(test)]
mod tests {
use regex::Regex;
use super::IPSource;
use super::IPSourceIpify;
#[tokio::test]
#[ignore]
async fn ipv4_test() {
let ipv4 = IPSourceIpify
.get_ipv4()
.await
.expect("Failed to get the IP address");
assert!(Regex::new(r"^\d+[.]\d+[.]\d+[.]\d+$")
.unwrap()
.is_match(ipv4.as_str()))
}
#[tokio::test]
#[ignore]
async fn ipv6_test() {
let ipv6 = IPSourceIpify
.get_ipv6()
.await
.expect("Failed to get the IP address");
assert!(Regex::new(r"^([0-9a-fA-F]*:){7}[0-9a-fA-F]*$")
.unwrap()
.is_match(ipv6.as_str()))
}
}

4
src/ip_source/mod.rs Normal file
View file

@ -0,0 +1,4 @@
pub(crate) mod common;
pub(crate) mod icanhazip;
pub(crate) mod ipify;
pub(crate) mod seeip;

55
src/ip_source/seeip.rs Normal file
View file

@ -0,0 +1,55 @@
use async_trait::async_trait;
use crate::ClientError;
use super::common::IPSource;
pub(crate) struct IPSourceSeeIP;
async fn get_ip(api_url: &str) -> Result<String, ClientError> {
let response = reqwest::get(api_url).await?;
let text = response.text().await?;
Ok(text)
}
#[async_trait]
impl IPSource for IPSourceSeeIP {
async fn get_ipv4(&self) -> Result<String, ClientError> {
get_ip("https://ip4.seeip.org").await
}
async fn get_ipv6(&self) -> Result<String, ClientError> {
get_ip("https://ip6.seeip.org").await
}
}
#[cfg(test)]
mod tests {
use regex::Regex;
use super::IPSource;
use super::IPSourceSeeIP;
#[tokio::test]
#[ignore]
async fn ipv4_test() {
let ipv4 = IPSourceSeeIP
.get_ipv4()
.await
.expect("Failed to get the IP address");
assert!(Regex::new(r"^\d+[.]\d+[.]\d+[.]\d+$")
.unwrap()
.is_match(ipv4.as_str()))
}
#[tokio::test]
#[ignore]
async fn ipv6_test() {
let ipv6 = IPSourceSeeIP
.get_ipv6()
.await
.expect("Failed to get the IP address");
assert!(Regex::new(r"^([0-9a-fA-F]*:){7}[0-9a-fA-F]*$")
.unwrap()
.is_match(ipv6.as_str()))
}
}

View file

@ -1,33 +1,63 @@
use crate::config::Config;
use anyhow;
use crate::gandi::GandiAPI;
use crate::ip_source::{common::IPSource, ipify::IPSourceIpify};
use clap::Parser;
use futures;
use config::{ConfigError, IPSourceName};
use ip_source::icanhazip::IPSourceIcanhazip;
use ip_source::seeip::IPSourceSeeIP;
use opts::Opts;
use reqwest::header::InvalidHeaderValue;
use reqwest::{header, Client, ClientBuilder, StatusCode};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::{num::NonZeroU32, sync::Arc, time::Duration};
use tokio::{self, task::JoinHandle};
use tokio::join;
use tokio::{self, task::JoinHandle, time::sleep};
mod config;
mod gandi;
mod ip_source;
mod opts;
use die_exit::*;
use governor;
use thiserror::Error;
/// 30 requests per minute, see https://api.gandi.net/docs/reference/
const GANDI_RATE_LIMIT: u32 = 30;
/// If we hit the rate limit, wait up to this many seconds before next attempt
const GANDI_DELAY_JITTER: u64 = 20;
fn gandi_api_url(fqdn: &str, rrset_name: &str, rrset_type: &str) -> String {
return format!(
" https://api.gandi.net/v5/livedns/domains/{}/records/{}/{}",
fqdn, rrset_name, rrset_type
);
#[derive(Error, Debug)]
pub enum ClientError {
#[error("Error occured while reading config: {0}")]
Config(#[from] ConfigError),
#[error("Error while accessing the Gandi API: {0}")]
Api(#[from] ApiError),
#[error("Error while converting the API key to a header: {0}")]
InvalidHeader(#[from] InvalidHeaderValue),
#[error("Error while sending request: {0}")]
Request(#[from] reqwest::Error),
#[error("Error while joining async tasks: {0}")]
TaskJoin(#[from] tokio::task::JoinError),
#[error("Unexpected type in config: {0}")]
BadEntry(String),
#[error("Entry '{0}' includes type A which requires an IPv4 adress but no IPv4 adress could be determined because: {1}")]
Ipv4missing(String, String),
#[error("Entry '{0}' includes type AAAA which requires an IPv6 adress but no IPv6 adress could be determined because: {1}")]
Ipv6missing(String, String),
}
fn api_client(api_key: &str) -> anyhow::Result<Client> {
#[derive(Error, Debug)]
pub enum ApiError {
#[error("API returned 403 - Forbidden. Message: {message:?}")]
Forbidden { message: String },
#[error("API returned 403 - Unauthorized. Provided API key is possibly incorrect")]
Unauthorized(),
#[error("API returned {0} - {0}")]
Unknown(StatusCode, String),
}
fn api_client(api_key: &str) -> Result<Client, ClientError> {
let client_builder = ClientBuilder::new();
let key = format!("Apikey {}", api_key);
let key = format!("Apikey {api_key}");
let mut auth_value = header::HeaderValue::from_str(&key)?;
let mut headers = header::HeaderMap::new();
auth_value.set_sensitive(true);
@ -35,13 +65,7 @@ fn api_client(api_key: &str) -> anyhow::Result<Client> {
let accept_value = header::HeaderValue::from_static("application/json");
headers.insert(header::ACCEPT, accept_value);
let client = client_builder.default_headers(headers).build()?;
return Ok(client);
}
async fn get_ip(api_url: &str) -> anyhow::Result<String> {
let response = reqwest::get(api_url).await?;
let text = response.text().await?;
Ok(text)
Ok(client)
}
#[derive(Serialize)]
@ -50,75 +74,475 @@ pub struct APIPayload {
pub rrset_ttl: u32,
}
#[derive(Debug)]
struct ResponseFeedback {
entry_name: String,
entry_type: String,
response: Result<String, ApiError>,
}
#[derive(Deserialize)]
// Allowing dead code because this is the API response we get from Gandi.
// We don't necessarily need all the fields, but we get them anyway.
#[allow(dead_code)]
struct ApiResponse {
message: String,
cause: Option<String>,
code: Option<i32>,
object: Option<String>,
}
async fn run(
base_url: &str,
ip_source: &Box<dyn IPSource>,
conf: &Config,
opts: &Opts,
) -> Result<(), ClientError> {
let mut last_ipv4: Option<String> = None;
let mut last_ipv6: Option<String> = None;
loop {
println!("Finding out the IP address...");
let (ipv4_result, ipv6_result) = join!(ip_source.get_ipv4(), ip_source.get_ipv6());
let ipv4 = ipv4_result.as_ref();
let ipv6 = ipv6_result.as_ref();
println!("Found these:");
match ipv4 {
Ok(ip) => println!("\tIPv4: {ip}"),
Err(err) => eprintln!("\tIPv4 failed: {err}"),
}
match ipv6 {
Ok(ip) => println!("\tIPv6: {ip}"),
Err(err) => eprintln!("\tIPv6 failed: {err}"),
}
let ipv4_same = last_ipv4
.as_ref()
.map(|p| ipv4.map(|q| p == q).unwrap_or(false))
.unwrap_or(false);
let ipv6_same = last_ipv6
.as_ref()
.map(|p| ipv6.map(|q| p == q).unwrap_or(false))
.unwrap_or(false);
if !ipv4_same || !ipv6_same || conf.always_update {
let client = api_client(&conf.api_key)?;
let mut tasks: Vec<JoinHandle<Result<ResponseFeedback, ClientError>>> = Vec::new();
println!("Attempting to update DNS entries now");
let governor = Arc::new(governor::RateLimiter::direct(governor::Quota::per_minute(
NonZeroU32::new(GANDI_RATE_LIMIT).die("Governor rate is 0"),
)));
let retry_jitter =
governor::Jitter::new(Duration::ZERO, Duration::from_secs(GANDI_DELAY_JITTER));
for entry in &conf.entry {
for entry_type in Config::types(entry) {
let fqdn = Config::fqdn(entry, conf).to_string();
let url = GandiAPI {
fqdn: &fqdn,
rrset_name: &entry.name,
rrset_type: entry_type,
base_url,
}
.url();
let ip = match entry_type {
"A" => match ipv4 {
Ok(ref value) => Ok(value),
Err(ref err) => Err(ClientError::Ipv4missing(
entry.name.clone(),
err.to_string(),
)),
},
"AAAA" => match ipv6 {
Ok(ref value) => Ok(value),
Err(ref err) => Err(ClientError::Ipv6missing(
entry.name.clone(),
err.to_string(),
)),
},
&_ => Err(ClientError::BadEntry(entry_type.to_string())),
}?;
let payload = APIPayload {
rrset_values: vec![ip.to_string()],
rrset_ttl: Config::ttl(entry, conf),
};
let req = client.put(url).json(&payload);
let task_governor = governor.clone();
let entry_type = entry_type.to_string();
let entry_name = entry.name.to_string();
let task: JoinHandle<Result<ResponseFeedback, ClientError>> =
tokio::task::spawn(async move {
task_governor.until_ready_with_jitter(retry_jitter).await;
println!("Updating {} record for {}", entry_type, &fqdn);
let resp = req.send().await?;
let response_feedback = match resp.status() {
StatusCode::CREATED => {
let body: ApiResponse = resp.json().await?;
ResponseFeedback {
entry_name,
entry_type,
response: Ok(body.message),
}
}
StatusCode::UNAUTHORIZED => ResponseFeedback {
entry_name,
entry_type,
response: Err(ApiError::Unauthorized()),
},
StatusCode::FORBIDDEN => {
let body: ApiResponse = resp.json().await?;
ResponseFeedback {
entry_name: entry_name.clone(),
entry_type,
response: Err(ApiError::Forbidden {
message: body.message,
}),
}
}
_ => {
let status = resp.status();
let body: ApiResponse = resp.json().await?;
ResponseFeedback {
entry_name,
entry_type,
response: Err(ApiError::Unknown(status, body.message)),
}
}
};
Ok(response_feedback)
});
tasks.push(task);
}
}
let results = futures::future::try_join_all(tasks).await?;
// Only count successfull requests
println!(
"Updates done for {} entries",
results
.iter()
.filter_map(|item| item.as_ref().ok())
.filter(|item| item.response.is_ok())
.count()
);
for item in &results {
match item {
Ok(value) => println!(
"{}",
match &value.response {
Ok(val) => format!(
"Record '{}' ({}): {}",
value.entry_name, value.entry_type, val
),
Err(err) => format!(
"Record '{}' ({}): {}",
value.entry_name, value.entry_type, err
),
}
),
Err(err) => println!("{err}"),
}
}
if results
.iter()
// all tasks finished OK, and all responses were OK as well
.all(|result| result.as_ref().map(|v| v.response.is_ok()).unwrap_or(false))
{
// Only then we update the last seen IP, because we want to
// retry updates in case the last update just happened to fail
last_ipv4 = ipv4.ok().map(|v| v.to_string());
last_ipv6 = ipv6.ok().map(|v| v.to_string());
} else if opts.repeat.is_some() {
println!("Some operations failed. They will be retried during the next repeat.")
}
} else {
println!("IP address has not changed since last update");
}
if let Some(repeat) = opts.repeat {
// If configured to repeat, do so
sleep(Duration::from_secs(repeat)).await;
continue;
}
// Otherwise this is one-shot, we should exit now
break;
}
Ok(())
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let opts = opts::Opts::parse();
let conf = config::load_config(&opts)
.die_with(|error| format!("Failed to read config file: {}", error));
config::validate_config(&conf).die_with(|error| format!("Invalid config: {}", error));
println!("Finding out the IP address...");
let ipv4_result = get_ip("https://api.ipify.org").await;
let ipv6_result = get_ip("https://api6.ipify.org").await;
let ipv4 = ipv4_result.as_ref();
let ipv6 = ipv6_result.as_ref();
println!("Found these:");
match ipv4 {
Ok(ip) => println!("\tIPv4: {}", ip),
Err(err) => eprintln!("\tIPv4 failed: {}", err),
}
match ipv6 {
Ok(ip) => println!("\tIPv6: {}", ip),
Err(err) => eprintln!("\tIPv6 failed: {}", err),
}
let conf = config::load_config(&opts)?;
let client = api_client(&conf.api_key)?;
let mut tasks: Vec<JoinHandle<(StatusCode, String)>> = Vec::new();
println!("Attempting to update DNS entries now");
let ip_source: Box<dyn IPSource> = match conf.ip_source {
IPSourceName::Ipify => Box::new(IPSourceIpify),
IPSourceName::Icanhazip => Box::new(IPSourceIcanhazip),
IPSourceName::SeeIP => Box::new(IPSourceSeeIP),
};
config::validate_config(&conf)?;
run("https://api.gandi.net", &ip_source, &conf, &opts).await?;
Ok(())
}
let governor = Arc::new(governor::RateLimiter::direct(governor::Quota::per_minute(
NonZeroU32::new(GANDI_RATE_LIMIT).die("Governor rate is 0"),
)));
let retry_jitter =
governor::Jitter::new(Duration::ZERO, Duration::from_secs(GANDI_DELAY_JITTER));
#[cfg(test)]
mod tests {
use crate::{config, ip_source::common::IPSource, opts::Opts, run, ClientError};
use async_trait::async_trait;
use httpmock::MockServer;
use lazy_static::lazy_static;
use std::{
env::temp_dir,
sync::atomic::{AtomicBool, Ordering::SeqCst},
time::Duration,
};
use tokio::{fs, task::LocalSet, time::sleep};
for entry in &conf.entry {
for entry_type in Config::types(entry) {
let fqdn = Config::fqdn(&entry, &conf).to_string();
let url = gandi_api_url(&fqdn, entry.name.as_str(), entry_type);
let ip = match entry_type {
"A" => ipv4.die_with(|error| format!("Needed IPv4 for {}: {}", fqdn, error)),
"AAAA" => ipv6.die_with(|error| format!("Needed IPv6 for {}: {}", fqdn, error)),
bad_entry_type => die!("Unexpected type in config: {}", bad_entry_type),
};
let payload = APIPayload {
rrset_values: vec![ip.to_string()],
rrset_ttl: Config::ttl(&entry, &conf),
};
let req = client.put(url).json(&payload);
let task_governor = governor.clone();
let task = tokio::task::spawn(async move {
task_governor.until_ready_with_jitter(retry_jitter).await;
println!("Updating {}", &fqdn);
match req.send().await {
Ok(response) => (
response.status(),
response
.text()
.await
.unwrap_or_else(|error| error.to_string()),
),
Err(error) => (StatusCode::IM_A_TEAPOT, error.to_string()),
}
});
tasks.push(task);
struct IPSourceMock;
#[async_trait]
impl IPSource for IPSourceMock {
async fn get_ipv4(&self) -> Result<String, ClientError> {
Ok("192.168.0.0".to_string())
}
async fn get_ipv6(&self) -> Result<String, ClientError> {
Ok("fe80:0000:0208:74ff:feda:625c".to_string())
}
}
let results = futures::future::try_join_all(tasks).await?;
println!("Updates done for {} entries", results.len());
for (status, body) in results {
println!("{} - {}", status, body);
#[tokio::test]
async fn single_shot() {
let mut temp = temp_dir().join("gandi-live-dns-test");
fs::create_dir_all(&temp)
.await
.expect("Failed to create test dir");
temp.push("test.toml");
fs::write(
&temp,
"fqdn = \"example.com\"\napi_key = \"xxx\"\nttl = 300\n[[entry]]\nname =\"@\"\n",
)
.await
.expect("Failed to write test config file");
let fqdn = "example.com";
let rname = "@";
let rtype = "A";
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method("PUT")
.path(format!(
"/v5/livedns/domains/{fqdn}/records/{rname}/{rtype}"
))
.body_contains("192.168.0.0");
then.status(201)
.body("{\"cause\":\"\", \"code\":201, \"message\":\"\", \"object\":\"\"}");
});
let opts = Opts {
config: Some(temp.to_string_lossy().to_string()),
..Opts::default()
};
let conf = config::load_config(&opts).expect("Failed to load config");
let ip_source: Box<dyn IPSource> = Box::new(IPSourceMock);
run(server.base_url().as_str(), &ip_source, &conf, &opts)
.await
.expect("Failed when running the update");
// Assert
mock.assert();
}
return Ok(());
#[test]
fn repeat() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
LocalSet::new().block_on(&runtime, async {
let mut temp = temp_dir().join("gandi-live-dns-test");
fs::create_dir_all(&temp)
.await
.expect("Failed to create test dir");
temp.push("test.toml");
fs::write(
&temp,
"fqdn = \"example.com\"\napi_key = \"xxx\"\nttl = 300\n[[entry]]\nname =\"@\"\n",
)
.await
.expect("Failed to write test config file");
let fqdn = "example.com";
let rname = "@";
let rtype = "A";
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method("PUT")
.path(format!(
"/v5/livedns/domains/{fqdn}/records/{rname}/{rtype}"
))
.body_contains("192.168.0.0");
then.status(201)
.body("{\"cause\":\"\", \"code\":201, \"message\":\"\", \"object\":\"\"}");
});
let server_url = server.base_url();
let handle = tokio::task::spawn_local(async move {
let opts = Opts {
config: Some(temp.to_string_lossy().to_string()),
repeat: Some(1),
..Opts::default()
};
let conf = config::load_config(&opts).expect("Failed to load config");
let ip_source: Box<dyn IPSource> = Box::new(IPSourceMock);
run(&server_url, &ip_source, &conf, &opts)
.await
.expect("Failed when running the update");
});
sleep(Duration::from_secs(3)).await;
handle.abort();
// Only should update once because the IP doesn't change
mock.assert();
});
}
#[test]
fn repeat_with_failure() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
LocalSet::new().block_on(&runtime, async {
let mut temp = temp_dir().join("gandi-live-dns-test");
fs::create_dir_all(&temp)
.await
.expect("Failed to create test dir");
temp.push("test.toml");
fs::write(
&temp,
"fqdn = \"example.com\"\napi_key = \"xxx\"\nttl = 300\n[[entry]]\nname =\"@\"\n",
)
.await
.expect("Failed to write test config file");
let fqdn = "example.com";
let rname = "@";
let rtype = "A";
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method("PUT")
.path(format!(
"/v5/livedns/domains/{fqdn}/records/{rname}/{rtype}"
))
.body_contains("192.168.0.0")
.matches(|_| {
// Don't match during the first call, but do during the second call
lazy_static! {
static ref FIRST_CALL: AtomicBool = AtomicBool::new(true);
}
if FIRST_CALL.load(SeqCst) {
FIRST_CALL.store(false, SeqCst);
return true;
}
false
});
then.status(500)
.body("{\"cause\":\"\", \"code\":500, \"message\":\"Something went wrong\", \"object\":\"\"}");
});
let mock_fail = server.mock(|when, then| {
when.method("PUT")
.path(format!(
"/v5/livedns/domains/{fqdn}/records/{rname}/{rtype}"
))
.body_contains("192.168.0.0");
then.status(201)
.body("{\"cause\":\"\", \"code\":201, \"message\":\"\", \"object\":\"\"}");
});
let server_url = server.base_url();
let handle = tokio::task::spawn_local(async move {
let opts = Opts {
config: Some(temp.to_string_lossy().to_string()),
repeat: Some(1),
..Opts::default()
};
let conf = config::load_config(&opts).expect("Failed to load config");
let ip_source: Box<dyn IPSource> = Box::new(IPSourceMock);
run(&server_url, &ip_source, &conf, &opts)
.await
.expect("Failed when running the update");
});
sleep(Duration::from_secs(4)).await;
handle.abort();
// The first call failed
mock_fail.assert();
// We then retried since the first call failed. The retry succeeds
// so we don't retry again.
mock.assert();
});
}
#[test]
fn repeat_always_update() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
LocalSet::new().block_on(&runtime, async {
let mut temp = temp_dir().join("gandi-live-dns-test");
fs::create_dir_all(&temp)
.await
.expect("Failed to create test dir");
temp.push("test.toml");
fs::write(
&temp,
"fqdn = \"example.com\"\nalways_update = true\napi_key = \"xxx\"\nttl = 300\n[[entry]]\nname =\"@\"\n",
)
.await
.expect("Failed to write test config file");
let fqdn = "example.com";
let rname = "@";
let rtype = "A";
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method("PUT")
.path(format!(
"/v5/livedns/domains/{fqdn}/records/{rname}/{rtype}"
))
.body_contains("192.168.0.0");
then.status(201).body("{\"cause\":\"\", \"code\":201, \"message\":\"\", \"object\":\"\"}");
});
let server_url = server.base_url();
let handle = tokio::task::spawn_local(async move {
let opts = Opts {
config: Some(temp.to_string_lossy().to_string()),
repeat: Some(1),
..Opts::default()
};
let conf = config::load_config(&opts).expect("Failed to load config");
let ip_source: Box<dyn IPSource> = Box::new(IPSourceMock);
run(&server_url, &ip_source, &conf, &opts)
.await
.expect("Failed when running the update");
});
sleep(Duration::from_secs(3)).await;
handle.abort();
// Should update multiple times since always_update
assert!(mock.hits() > 1);
});
}
}

View file

@ -1,11 +1,30 @@
use clap::Parser;
/// A tool to automatically update DNS entries on Gandi, using it as a dynamic DNS system.
#[derive(Parser, Debug)]
#[derive(Parser, Debug, Default)]
#[clap(author, version, about, long_about = None, name = "gandi-live-dns")]
pub struct Opts {
/// The path to the configuration file.
#[clap(long)]
pub config: Option<String>,
/// Skip IPv4 updates.
///
/// If enabled, any IPv4 (A) records in the configuration file are ignored.
#[clap(action, long)]
pub skip_ipv4: bool,
/// Skip IPv4 updates.
///
/// If enabled, any IPv6 (AAAA) records in the configuration file are ignored.
#[clap(action, long)]
pub skip_ipv6: bool,
/// Repeat after specified delay, in seconds.
///
/// If enabled, this will continue to run and perform the updates
/// periodically. The first update will happen immediately, and later
/// updates will be delayed by this many seconds.
///
/// This process will not fork, so you may need to use something like
/// `nohup` to keep it running in the background.
#[clap(long)]
pub repeat: Option<u64>,
}