mirror of
https://github.com/SeriousBug/gandi-live-dns-rust
synced 2024-05-19 03:39:04 -05:00
Compare commits
67 commits
Author | SHA1 | Date | |
---|---|---|---|
Kaan Barmore-Genç | 847b6eb1c5 | ||
Kaan Barmore-Genç | 706251c4d8 | ||
Kaan Barmore-Genç | d4dffb19e9 | ||
Kaan Barmore-Genç | 5c6b38f7b0 | ||
8413555d2f | |||
Kaan Barmore-Genç | 5cdd7b9e83 | ||
Kaan Barmore-Genç | 1bb4c7af1c | ||
Kaan Barmore-Genç | b71a78118b | ||
Kaan Barmore-Genç | 27a60d3ac2 | ||
Kaan Barmore-Genç | 327b14a00a | ||
Kaan Barmore-Genç | f8060fad42 | ||
Kaan Barmore-Genç | 7e7a9da65e | ||
Kaan Barmore-Genç | e95cf42b69 | ||
4ada0b7fb4 | |||
d52ca4b840 | |||
Kaan Barmore-Genç | ed83f7dedc | ||
Kaan Barmore-Genç | f94222f048 | ||
Kaan Barmore-Genç | e2a343c59e | ||
Kaan Barmore-Genç | 220e368bf3 | ||
041c1109e0 | |||
bbd7ce347a | |||
Kaan Barmore-Genç | 28984e1b52 | ||
Kaan Barmore-Genç | 35d60f0b29 | ||
Kaan Barmore-Genç | 7cffca51af | ||
Kaan Barmore-Genç | 1250e512f9 | ||
Kaan Barmore-Genç | 985ce8ea5c | ||
Kaan Barmore-Genç | 984449f748 | ||
Kaan Barmore-Genç | 039d8933ad | ||
Kaan Barmore-Genç | 5755aedc2f | ||
Kaan Barmore-Genç | 84bef554b0 | ||
Kaan Barmore-Genç | 3daee43540 | ||
Kaan Barmore-Genç | e91a9c5c4f | ||
Kaan Barmore-Genç | eaabec35b4 | ||
Kaan Barmore-Genç | 98e2931493 | ||
Kaan Barmore-Genç | 90c5485cce | ||
Kaan Barmore-Genç | 80d8c8885b | ||
Kaan Barmore-Genç | 49154e5f2f | ||
Kaan Barmore-Genç | 21dcd000a7 | ||
Kaan Barmore-Genç | e556c00901 | ||
Kaan Barmore-Genç | 05a3fb6c89 | ||
Kaan Barmore-Genç | a9629ad4a3 | ||
c05b0a8494 | |||
Kaan Barmore-Genç | 661dfc55fd | ||
e4938a3f95 | |||
df672c0f48 | |||
Kaan Barmore-Genç | ffa22a6f8d | ||
Kaan Barmore-Genç | d22031782f | ||
3d41d3ae44 | |||
85588f8c95 | |||
Kaan Barmore-Genç | 625a2b6b03 | ||
Kaan Barmore-Genç | 7ab0320d72 | ||
9c61ea9bf1 | |||
04eac21083 | |||
Kaan Barmore-Genç | 496467d257 | ||
Kaan Barmore-Genç | f431668aec | ||
Kaan Barmore-Genç | 058c9ac769 | ||
Kaan Barmore-Genç | 4032fbe7ff | ||
Kaan Barmore-Genç | 8faa6bd5f1 | ||
Kaan Barmore-Genç | b61e3d76cf | ||
Kaan Barmore-Genç | ef7fa14eaa | ||
Kaan Barmore-Genç | 2f9cdbd105 | ||
Kaan Barmore-Genç | f13c5aa6a6 | ||
Kaan Barmore-Genç | 19fa71de41 | ||
Kaan Barmore-Genç | fba01cb4e8 | ||
Kaan Barmore-Genç | c62b8c9f69 | ||
Kaan Barmore-Genç | 6667b62747 | ||
Kaan Barmore-Genç | 87bc572111 |
25
.all-contributorsrc
Normal file
25
.all-contributorsrc
Normal 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
3
.clippy.toml
Normal 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
4
.codecov.yml
Normal 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"
|
12
.github/dependabot.yml
vendored
Normal file
12
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "saturday"
|
17
.github/workflows/lint.yml
vendored
Normal file
17
.github/workflows/lint.yml
vendored
Normal 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
25
.github/workflows/test.yml
vendored
Normal 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
3
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
/target
|
||||
gandi.toml
|
||||
*.tar*
|
||||
*.zip
|
||||
*.zip
|
||||
*.sig
|
||||
|
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -3,5 +3,6 @@
|
|||
"gandi",
|
||||
"rrset",
|
||||
"structopt"
|
||||
]
|
||||
],
|
||||
"editor.formatOnSave": true
|
||||
}
|
1688
Cargo.lock
generated
1688
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
47
Cargo.toml
47
Cargo.toml
|
@ -1,22 +1,45 @@
|
|||
[package]
|
||||
name = "gandi-live-dns"
|
||||
version = "1.0.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 = true
|
||||
strip = "symbols"
|
||||
lto = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
reqwest = { version = "0.11.7", default-features= false, features = ["json", "rustls-tls"] }
|
||||
toml = "0.5.8"
|
||||
json = "0.12.4"
|
||||
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.1"
|
||||
structopt = "0.3.25"
|
||||
tokio = { version = "1.14.0", features = ["full"] }
|
||||
futures = "0.3.17"
|
||||
directories = "4.0"
|
||||
clap = { version = "4.0", features = [
|
||||
"derive",
|
||||
"cargo",
|
||||
"unicode",
|
||||
"wrap_help",
|
||||
] }
|
||||
tokio = { version = "1.23", features = ["full"] }
|
||||
futures = "0.3"
|
||||
anyhow = "1.0"
|
||||
# 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"]
|
||||
|
|
3
Packaging/.gitignore
vendored
Normal file
3
Packaging/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
pkg/
|
||||
src/
|
||||
example.toml
|
4
Packaging/gandi-live-dns.install
Normal file
4
Packaging/gandi-live-dns.install
Normal file
|
@ -0,0 +1,4 @@
|
|||
post_install() {
|
||||
systemd-sysusers gandi-live-dns.conf
|
||||
chown -R gandi-live-dns:gandi-live-dns /etc/gandi-live-dns
|
||||
}
|
14
Packaging/gandi-live-dns.service
Normal file
14
Packaging/gandi-live-dns.service
Normal file
|
@ -0,0 +1,14 @@
|
|||
[Unit]
|
||||
Description=Automatically updates your IP adress with Gandi LiveDNS.
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/gandi-live-dns
|
||||
User=gandi-live-dns
|
||||
Group=gandi-live-dns
|
||||
WorkingDirectory=/etc/gandi-live-dns/
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
1
Packaging/gandi-live-dns.sysusers
Normal file
1
Packaging/gandi-live-dns.sysusers
Normal file
|
@ -0,0 +1 @@
|
|||
u gandi-live-dns - "Gandi LiveDNS updater" /etc/gandi-live-dns
|
11
Packaging/gandi-live-dns.timer
Normal file
11
Packaging/gandi-live-dns.timer
Normal file
|
@ -0,0 +1,11 @@
|
|||
[Unit]
|
||||
Description=Automatically updates your IP adress with Gandi LiveDNS.
|
||||
Wants=network-online.target
|
||||
|
||||
[Timer]
|
||||
OnBootSec=1s
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=10s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
203
README.md
Normal file
203
README.md
Normal 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 -->
|
76
Readme.md
76
Readme.md
|
@ -1,76 +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.
|
||||
|
||||
Inspired by [cavebeat's similar tool](https://github.com/cavebeat/gandi-live-dns),
|
||||
which seems to be unmaintained at the time I'm writing this. I decided to rewrite
|
||||
it in Rust as a learning project.
|
||||
|
||||
## Usage
|
||||
|
||||
> Warning!
|
||||
>
|
||||
> This tool does not rate limit itself, or otherwise do anything that limits how
|
||||
> often it sends changes to Gandi's servers. It's up to you to use the tool
|
||||
> properly and avoid abusing Gandi's servers. The tool is one-shot, so all you
|
||||
> have to do is to avoid running it too often.
|
||||
|
||||
### Prebuilt binaries
|
||||
|
||||
`gandi-live-dns-rust` 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
|
||||
|
||||
`gandi-live-dns-rust` has Docker images 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. Check the linked page to enable it, or use the native option.
|
||||
|
||||
> 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.
|
||||
|
||||
## 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/)
|
||||
- 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
|
16
example.toml
16
example.toml
|
@ -9,6 +9,20 @@ fqdn = "example.com"
|
|||
# otherwise do things that will cause you to be charged money.
|
||||
api_key = "xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
# The Time To Live value to be used by entries. This can be an integer between
|
||||
# 300 and 2592000. It is 300 by default. This is roughly how quickly DNS changes
|
||||
# will propagate when updated, you should keep this the minimum so changes to
|
||||
# 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]]
|
||||
|
@ -24,3 +38,5 @@ types = ["A", "AAAA"]
|
|||
# Updates A for some.example.net
|
||||
name = "some"
|
||||
fqdn = "example.net" # Overrides top level setting
|
||||
# Individual entries can override the global TTL
|
||||
ttl = 600
|
||||
|
|
|
@ -3,10 +3,9 @@
|
|||
# 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
|
||||
set -Eeuo pipefail
|
||||
function handle_sigint() {
|
||||
|
@ -43,19 +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
|
||||
7z a -tzip "gandi-live-dns.${VERSION}.${TARGETS[${target}]}.zip" target/"${target}"/release/gandi-live-dns.exe 1>/dev/null
|
||||
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" target/"${target}"/release/gandi-live-dns
|
||||
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
|
||||
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
|
||||
|
@ -70,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
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "stable"
|
||||
|
|
266
src/config.rs
266
src/config.rs
|
@ -1,52 +1,97 @@
|
|||
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 {
|
||||
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 {
|
||||
return entry.fqdn.as_ref().unwrap_or(&config.fqdn).as_str();
|
||||
entry.fqdn.as_ref().unwrap_or(&config.fqdn).as_str()
|
||||
}
|
||||
|
||||
pub fn types<'e>(entry: &'e Entry) -> Vec<&'e str> {
|
||||
return 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 ttl(entry: &Entry, config: &Config) -> u32 {
|
||||
entry.ttl.unwrap_or(config.ttl)
|
||||
}
|
||||
|
||||
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());
|
||||
|
@ -58,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
15
src/gandi/mod.rs
Normal 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
6
src/ip_source/Readme.md
Normal 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
9
src/ip_source/common.rs
Normal 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>;
|
||||
}
|
64
src/ip_source/icanhazip.rs
Normal file
64
src/ip_source/icanhazip.rs
Normal 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
55
src/ip_source/ipify.rs
Normal 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
4
src/ip_source/mod.rs
Normal 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
55
src/ip_source/seeip.rs
Normal 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()))
|
||||
}
|
||||
}
|
591
src/main.rs
591
src/main.rs
|
@ -1,25 +1,63 @@
|
|||
use crate::config::Config;
|
||||
use anyhow;
|
||||
use futures;
|
||||
use crate::gandi::GandiAPI;
|
||||
use crate::ip_source::{common::IPSource, ipify::IPSourceIpify};
|
||||
use clap::Parser;
|
||||
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 std::collections::HashMap;
|
||||
use structopt::StructOpt;
|
||||
use tokio::{self, task::JoinHandle};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{num::NonZeroU32, sync::Arc, time::Duration};
|
||||
use tokio::join;
|
||||
use tokio::{self, task::JoinHandle, time::sleep};
|
||||
mod config;
|
||||
mod gandi;
|
||||
mod ip_source;
|
||||
mod opts;
|
||||
use die_exit::*;
|
||||
use thiserror::Error;
|
||||
|
||||
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
|
||||
);
|
||||
/// 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;
|
||||
|
||||
#[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);
|
||||
|
@ -27,75 +65,484 @@ 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);
|
||||
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)
|
||||
#[derive(Serialize)]
|
||||
pub struct APIPayload {
|
||||
pub rrset_values: Vec<String>,
|
||||
pub rrset_ttl: u32,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let opts = opts::Opts::from_args();
|
||||
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 client = api_client(&conf.api_key)?;
|
||||
let mut tasks: Vec<JoinHandle<(StatusCode, String)>> = Vec::new();
|
||||
println!("Attempting to update DNS entries now");
|
||||
#[derive(Debug)]
|
||||
struct ResponseFeedback {
|
||||
entry_name: String,
|
||||
entry_type: String,
|
||||
response: Result<String, ApiError>,
|
||||
}
|
||||
|
||||
for entry in &conf.entry {
|
||||
for entry_type in Config::types(entry) {
|
||||
let fqdn = Config::fqdn(&entry, &conf);
|
||||
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 mut map = HashMap::new();
|
||||
map.insert("rrset_values", vec![ip]);
|
||||
let req = client.put(url).json(&map);
|
||||
let task = tokio::task::spawn(async move {
|
||||
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()),
|
||||
#[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);
|
||||
}
|
||||
});
|
||||
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)?;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
#[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};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
32
src/opts.rs
32
src/opts.rs
|
@ -1,14 +1,30 @@
|
|||
use structopt::StructOpt;
|
||||
use clap::Parser;
|
||||
|
||||
/// A tool to automatically update DNS entries on Gandi, using it as a dynamic DNS system.
|
||||
#[derive(StructOpt, Debug)]
|
||||
#[structopt(name = "gandi-live-dns")]
|
||||
#[derive(Parser, Debug, Default)]
|
||||
#[clap(author, version, about, long_about = None, name = "gandi-live-dns")]
|
||||
pub struct Opts {
|
||||
/// The path to the configuration file.
|
||||
#[structopt(long)]
|
||||
#[clap(long)]
|
||||
pub config: Option<String>,
|
||||
|
||||
/// If set, it will only update the DNS once then exit.
|
||||
#[structopt(long)]
|
||||
pub oneshot: bool,
|
||||
/// 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>,
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue