A Rust client that keeps one or more No-IP hostnames (https://www.noip.com/) pointed at your current public IP.
It runs in one of three ways: as a systemd timer (default), as a long-lived daemon (--daemon), or in the background on Termux or a notebook with the included noipctl.sh script. It is safe against transient network failures, honors No-IP's abuse rate limit, and can notify you on Telegram on every run, or only when something changed.
- Multiple hosts - each
[[hosts]]entry is tracked independently, with its own IP family, provider override, and Telegram silent policy. - IPv4 and IPv6 - pick
ip_version = 4or6per host. IPv6 uses AAAA records on No-IP. - Multiple IP providers - api.ipify.org, icanhazip.com, ifconfig.me/ip, ipinfo.io/ip. Tried in randomized order per run; the first successful response wins.
- Validates the detected IP - rejects private, loopback, link-local, broadcast, multicast, unspecified, IPv6 ULA (fc00::/7), and IPv6 link-local (fe80::/10) addresses.
- Only updates No-IP when needed - the last successful IP is kept in a JSON state file.
- Forced refresh - No-IP expires hostnames that are not updated for about 30 days. The client forces an update every
force_update_after(default25d). - Retry with exponential backoff - transient errors and No-IP
911responses are retried up to 3 times per run. - Abuse backoff - if No-IP returns
abuse, the host is skipped for 30 minutes. - Atomic state writes - temp file plus rename,
0600permissions on Unix. - Telegram notifications - optional, multi-recipient, with a status message on every run. Silent on unchanged runs if configured.
- Device details on Termux - status messages include model, Android version, memory, storage, battery, and network addresses when running on Android/Termux.
- Prometheus textfile output - optional.
- Guided setup -
--initinteractively creates a minimal config and a separate, protected credentials file. - Small binary - release profile with LTO and stripped symbols.
- Rust 1.90 or newer (the crate uses edition 2024, and some dependencies need 1.88+)
- A No-IP account with one or more active hostnames
- (Optional) A Telegram bot
- (Optional) Docker, for building Android binaries with the included
Dockerfile
Build the release binary for this machine:
cargo build --releaseThe binary is written to target/release/noipclient.
The project includes a justfile with the common tasks. Install just (cargo install just, or your package manager), then run just to list them:
| Recipe | What it does |
|---|---|
just build |
Native release build |
just build-debug |
Debug build |
just run / just run-release |
Run the client (lints first) |
just check |
Formatting check, clippy and tests |
just check-all |
Everything that must pass before a release: clippy, tests, format check, audit and outdated |
just fmt / just fmt-check |
Format the code / check formatting without changing files |
just clippy |
Clippy on all targets and features, warnings are errors |
just fix |
Apply clippy suggestions automatically, then re-check |
just test / just test-verbose / just test-std |
Tests with cargo-nextest, or the standard test harness |
just test-all |
cargo-nextest plus doc tests |
just audit / just outdated / just udeps |
Security audit, outdated dependencies, unused dependencies (nightly) |
just expand / just flamegraph / just llvm-lines / just tarpaulin |
Code analysis and coverage |
just size |
Size contribution of each crate in the release binary (cargo-bloat) |
just update / just add <crate> / just rm <crate> |
Manage dependencies |
just semver-check / just publish-dry |
Check API compatibility and package for crates.io without publishing |
just release |
Check everything, then create and push the vX.Y.Z tag from Cargo.toml to start the GitHub release and the crates.io publish |
just release-redo |
Delete a failed release and its tags (GitHub, local, remote) so it can be cut again. Refused if the version is already on crates.io |
just watch [cmd] |
Re-run a command on file changes (default: run) |
just termux |
Build one Termux binary with cross and package it (arm64 by default) |
just termux-all |
Build all three Termux targets with cross |
just docker-termux |
Build one Termux binary with the Dockerfile and package it |
just docker-termux-all |
Build all three Termux targets with the Dockerfile |
just docker-termux-clean |
Build with Docker, then remove all Docker data |
just docker-clean |
Remove all Docker containers, images, volumes, networks, and build cache |
just clean |
Remove target/ and dist/ |
Quality and analysis recipes need their tools installed once. Each recipe lists the cargo install command in the justfile, for example cargo install cargo-nextest --locked. The license and dependency checks run in CI with cargo deny check, configured by deny.toml.
Packages are written to dist/. Each Termux package is named dist/noipclient-<version>-<target>.tar.gz and contains the binary and noip.toml.example.
The Dockerfile cross-compiles for Android without a host Android toolchain. Docker must be running on the build machine (on NixOS: virtualisation.docker.enable = true; and add your user to the docker group).
just docker-termux # arm64, most phones
just docker-termux armv7-linux-androideabi # older 32-bit phonesUse just docker-termux-clean to remove all Docker data after the build. This also deletes the base image, so the next build downloads it again. It affects every Docker project on the machine.
Docker runs on the build machine only. Termux does not run Docker; the phone just runs the binary.
A minimal config:
[noip]
user_agent = "noipclient/0.2 you@example.com"
username = "you@example.com"
password = "your-password"
[[hosts]]
hostname = "yourhost.ddns.net"
ip_version = 4A more complete config:
# Global defaults; per-host overrides are possible.
check_interval_secs = 300
ip_check_url = "https://api.ipify.org"
state_file = "/var/lib/noipclient/state.json"
prometheus_file = "/var/lib/node_exporter/textfile/noip.prom"
force_update_after = "25d"
silent_on_unchanged = true
[noip]
user_agent = "noipclient/0.2 you@example.com"
credentials_file = "/etc/noipclient/credentials"
[telegram]
token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
chat_ids = ["123456789"]
silent_on_unchanged = true
[[hosts]]
hostname = "home.ddns.net"
ip_version = 4
[[hosts]]
hostname = "vpn.ddns.net"
ip_version = 4
[[hosts]]
hostname = "home-v6.ddns.net"
ip_version = 6A ready-to-edit copy is in noip.toml.example.
Top level:
| Key | Default | Notes |
|---|---|---|
check_interval_secs |
300 |
Only used with --daemon. Minimum 30. |
ip_check_url |
https://api.ipify.org |
Default for hosts that do not override it. |
state_file |
noip-state.json |
JSON; created if missing. Use an absolute path under a service or cron. |
prometheus_file |
unset | If set, metrics are written here. |
force_update_after |
25d |
s, m, h, and d suffixes are accepted. |
silent_on_unchanged |
false |
Telegram disable_notification on runs without a change. |
[noip]:
| Key | Required | Notes |
|---|---|---|
user_agent |
yes | No-IP requires a real contact address. |
username / password |
one of | Inline credentials. |
credentials_file |
one of | First two non-empty lines: username, then password. |
[telegram] (all optional):
| Key | Notes |
|---|---|
token |
Bot token from @BotFather. |
chat_ids |
Array of chat IDs. Group chat IDs are negative. |
silent_on_unchanged |
Skip the notification sound on runs without a change. |
[[hosts]] (one or more required):
| Key | Default | Notes |
|---|---|---|
hostname |
- | The No-IP hostname to update. |
ip_version |
4 |
4 or 6. |
ip_check_url |
inherited | Override the IP provider for this host. |
silent_on_unchanged |
inherited | Override the Telegram silent policy for this host. |
Paths in the config are not expanded: ~ and environment variables do not work. Use absolute paths.
sudo install -d -m 0750 /etc/noipclient
sudo tee /etc/noipclient/credentials >/dev/null <<'EOT'
you@example.com
your-password
EOT
sudo chmod 600 /etc/noipclient/credentials- Message @BotFather on Telegram, send
/newbot, and copy the token. - Send any message to your new bot.
- Open
https://api.telegram.org/bot<TOKEN>/getUpdatesin a browser and find"chat":{"id":...}. That number is the chat ID. Group chat IDs are negative.
Each run sends a status message. The first line says what happened. Example from a notebook (x86_64):
✅ Client running, no change
Program: No-IP Client v0.3.0
DNS: home.ddns.net
Current IP: 203.0.113.7
Duration: 0.41s
Last successful update: 3h 12m ago
Time: 2026-10-13 14:05:00 UTC
Machine: homeserver-01
When the IP changes, the first line is 🔄 IP CHANGED, followed by the new IP. The message does not include the previous address.
On Termux, the same message also includes a device section:
✅ Client running, no change
Program: No-IP Client v0.3.0
DNS: home.ddns.net
Current IP: 192.168.1.13
Duration: 0.63s
Last successful update: 1h 4m ago
Time: 2026-10-13 14:05:00 UTC
Machine: unknown
--- Device ---
📱 Device: Xiaomi Mi 9 Lite
🤖 Android: 11 (API 30)
📦 Termux: 0.118.3
🖥️ Architecture: aarch64
🧠 CPU cores: 6
🔄 Uptime: 2d 14h
👤 User: u0_a253
🌐 Local IP: 192.168.1.13
🌐 wlan0 IP: 192.168.1.13
💾 Memory total: 5.49 GB
💾 Memory available: 3.24 GB
📀 Storage total: 108.89 GB
📀 Storage free: 99.21 GB
🔋 Battery: 100% (FULL)
On Termux, Machine falls back to Android's hostname or device model when /etc/hostname is missing. The wlan0 IP lookup falls back to termux-wifi-connectioninfo (from the optional Termux:API app) and then the local network address when Android restricts interface commands.
Usage: noipclient [OPTIONS]
Options:
-c, --config <CONFIG> Path to TOML config [env: NOIP_CONFIG]
--init Interactively create a config and credentials file
--dry-run Don't contact No-IP or Telegram
--force Update every host now, ignoring local state
--noisy Ignore silent_on_unchanged
--status Print state for each host and exit
--daemon Loop internally instead of relying on a timer
-v, --verbose... Increase verbosity; -vv enables debug output
-h, --help Print help
-V, --version Print version
Run the guided setup to create a minimal configuration:
noipclient --initAnswer the prompts for your No-IP username/email, hostname, and address family. The
password is entered without being echoed. The command creates noip.toml and
noip.credentials in the current directory. On Unix, both files are restricted to
the current user (0600). It refuses to overwrite either file.
Use --config to choose another config path; the credentials file is created beside
it using the same basename and a .credentials extension:
noipclient --init --config ~/.config/noipclient/noip.tomlThe generated config references the credentials file by its absolute path. Keep both files available to the account that runs NoIPClient. Setup does not contact No-IP or verify the credentials. Run a dry-run to test public-IP detection and inspect the planned update without sending an update request:
noipclient --config noip.toml --dry-runnoipclient --config /etc/noipclient/noip.toml
noipclient --force --noisy
noipclient --dry-run -vv
noipclient --status --config /etc/noipclient/noip.toml
noipclient --daemon --config /etc/noipclient/noip.tomlCreate noip.toml (see Configuration). Start with a dry run, which contacts neither No-IP nor Telegram:
./target/release/noipclient --config noip.toml --dry-run -vvThen run it for real:
./target/release/noipclient --config noip.tomlnoipctl.sh starts the daemon in the background with & and stores its PID, so stop only ends the process it started. It works on Termux and on Linux (including notebooks). It uses /bin/sh and needs no extra tools.
Commands:
noipctl.sh start start the daemon in the background
noipctl.sh stop stop it (SIGTERM, then SIGKILL after 10 seconds)
noipctl.sh restart stop and start
noipctl.sh status show whether it is running and its PID
noipctl.sh log follow the log (Ctrl+C to exit)
Default paths are in $HOME/noip:
| Item | Default | Variable |
|---|---|---|
| Directory | $HOME/noip |
NOIP_DIR |
| Binary | $NOIP_DIR/noipclient |
NOIP_BIN |
| Config | $NOIP_DIR/noip.toml |
NOIP_CONFIG |
| Log | $NOIP_DIR/noip.log |
NOIP_LOG |
| PID file | $NOIP_DIR/noipclient.pid |
NOIP_PIDFILE |
mkdir -p ~/noip
tar xzf noipclient-*-aarch64-linux-android.tar.gz -C ~/noip
mv ~/noip/noip.toml.example ~/noip/noip.toml # then edit it
chmod 600 ~/noip/noip.toml
cp noipctl.sh ~/noip/ && chmod +x ~/noip/noipctl.sh
~/noip/noipctl.sh start
~/noip/noipctl.sh statusUse absolute paths in noip.toml, for example state_file = "/data/data/com.termux/files/home/noip/state.json".
On Termux, the process does not survive closing the session if it was started with &, and it does not survive a phone reboot. Run termux-wake-lock to keep Android from suspending it. For automatic start after reboot, use Termux:Boot to run noipctl.sh start.
Build the binary, then run the script with the paths pointing to your files:
cargo build --release
NOIP_BIN=./target/release/noipclient \
NOIP_CONFIG=./noip.toml \
NOIP_DIR=/tmp/noip \
./noipctl.sh startIf you want it to start automatically at login or after a reboot, use the systemd setup below instead of the script.
Use the systemd setup on NixOS or any Linux distribution that uses systemd. The examples below use a dedicated noip user, so the configuration file containing your password or Telegram token is readable only by that user and root.
Add this to configuration.nix (or to an imported module):
{ config, pkgs, lib, ... }:
let
noipclient = pkgs.rustPlatform.buildRustPackage {
pname = "noipclient";
version = "0.3.0";
src = /path/to/NoIPClient; # or a flake input
cargoLock.lockFile = /path/to/NoIPClient/Cargo.lock;
};
in
{
users.users.noip = {
isSystemUser = true;
group = "noip";
};
users.groups.noip = { };
# Config directory: root-owned, readable by the noip group.
systemd.tmpfiles.rules = [
"d /etc/noipclient 0750 root noip -"
];
systemd.services.noipclient = {
description = "No-IP dynamic DNS update";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
User = "noip";
Group = "noip";
ExecStart = "${noipclient}/bin/noipclient --config /etc/noipclient/noip.toml";
StateDirectory = "noipclient"; # /var/lib/noipclient, writable by the service
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = true;
};
};
systemd.timers.noipclient = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "1min";
OnUnitActiveSec = "5min";
Persistent = true;
};
};
}Create the configuration file outside the Nix store (it contains secrets):
sudo install -o noip -g noip -m 0600 noip.toml.example /etc/noipclient/noip.toml
sudo $EDITOR /etc/noipclient/noip.tomlSet the state file to the service directory:
state_file = "/var/lib/noipclient/state.json"Rebuild and test:
sudo nixos-rebuild switch
sudo systemctl start noipclient.service
journalctl -u noipclient.service -n 30 --no-pager
systemctl list-timers noipclient.timer
sudo -u noip noipclient --status --config /etc/noipclient/noip.tomlIf you enable prometheus_file, the directory must be writable by the service. Add ReadWritePaths = [ "/var/lib/node_exporter/textfile" ]; to serviceConfig.
Install the binary and the configuration:
sudo install -m 0755 target/release/noipclient /usr/local/bin/noipclient
sudo useradd --system --home-dir /var/lib/noipclient --shell /usr/sbin/nologin noip
sudo install -d -m 0750 -o root -g noip /etc/noipclient
sudo install -o noip -g noip -m 0600 noip.toml.example /etc/noipclient/noip.toml
sudo $EDITOR /etc/noipclient/noip.tomlService unit, /etc/systemd/system/noipclient.service:
[Unit]
Description=No-IP dynamic update check
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=noip
Group=noip
ExecStart=/usr/local/bin/noipclient --config /etc/noipclient/noip.toml
StateDirectory=noipclient
WorkingDirectory=/var/lib/noipclientTimer unit, /etc/systemd/system/noipclient.timer:
[Unit]
Description=Run noipclient periodically
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.targetEnable the timer:
sudo systemctl daemon-reload
sudo systemctl enable --now noipclient.timerInstead of the timer, you can run the client as a daemon that checks on its own interval (check_interval_secs):
[Unit]
Description=No-IP dynamic update client (daemon)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=noip
Group=noip
ExecStart=/usr/local/bin/noipclient --daemon --config /etc/noipclient/noip.toml
Restart=on-failure
StateDirectory=noipclient
WorkingDirectory=/var/lib/noipclient
[Install]
WantedBy=multi-user.targetUse either the timer or the daemon, not both.
When prometheus_file is set, the client writes a textfile with one set of values per host:
noip_last_success_timestamp{host="home.ddns.net"} 1730000000
noip_seconds_since_success{host="home.ddns.net"} 42
noip_failures{host="home.ddns.net"} 0
noip_blocked_seconds{host="home.ddns.net"} 0
systemctl list-timers noipclient.timer
journalctl -u noipclient.service -n 50
journalctl -u noipclient.service -f
sudo systemctl start noipclient.service
noipclient --status --config /etc/noipclient/noip.toml- no No-IP credentials - set
noip.usernameandnoip.password, ornoip.credentials_fileinnoip.toml. - reading credentials ... - the file is missing, or the service user cannot read it.
- No-IP rejected the update: badauth - wrong username or password.
- No-IP rejected the update: badagent -
noip.user_agentdoes not meet No-IP's policy. - non-public IPv4 / IPv6 - the provider returned a private or reserved address. If your ISP uses CGNAT, your address is in
100.64.0.0/10, which this check does not reject. - provider returned wrong family - check
ip_versionandip_check_url. - Telegram notification failed - check
tokenandchat_ids, and make sure you sent a message to the bot first. - state file corrupt - the client discards the file and starts fresh.
- Hostname does not update even though No-IP returns
good- free No-IP hostnames require periodic confirmation. Check your No-IP dashboard. - Paths not found on Termux or cron -
~and environment variables are not expanded. Use absolute paths.
noip.tomlshould bechmod 600(or640with a group the service uses). The credentials file should bechmod 600.- Do not put the configuration file in the Nix store or in version control.
- The client redacts the Telegram token from error messages. Avoid sharing logs that may contain other sensitive values.
- Use a
noip.user_agentwith a real contact address that you monitor. - On Termux, if you use key-based SSH, disable password login in
sshd_config.
| Old env var | New TOML key |
|---|---|
NOIP_HOSTNAME |
[[hosts]].hostname |
NOIP_USERNAME |
noip.username |
NOIP_PASSWORD |
noip.password |
NOIP_USER_AGENT |
noip.user_agent |
IP_CHECK_URL |
ip_check_url |
STATE_FILE |
state_file |
TELEGRAM_BOT_TOKEN |
telegram.token |
TELEGRAM_CHAT_ID |
telegram.chat_ids |
CHECK_INTERVAL_SECS |
check_interval_secs |
See CHANGELOG.md.
Choose what to be notified about, and how often, in noip.toml:
[notify]
channel = "telegram" # "telegram" | "email" | "none"
frequency = "change" # "always" | "change" | "failure"channel: where the message goes.telegramuses the[telegram]section,emailuses[email], andnonedisables notifications.frequency:always: a status message on every run.change: only when the public IP changed.failure: only when an update failed.
Without a [notify] section, the previous behaviour is kept: a Telegram status message on every run, if [telegram] is configured.
Email is sent through an HTTP email API. The default endpoint is Resend's, which accepts the JSON format below. Any provider with a compatible endpoint can be used by setting api_url:
[email]
api_key = "re_xxxxxxxx" # keep this file readable only by you (chmod 600)
from = "NoIPClient <alerts@yourdomain.com>"
to = ["you@example.com"]The program refuses to start if the chosen channel is not fully configured.
MIT. See the license field in Cargo.toml.

