Compare commits

...

12 Commits

9 changed files with 558 additions and 6 deletions
+3
View File
@@ -52,3 +52,6 @@ Module.symvers
Mkfile.old Mkfile.old
dkms.conf dkms.conf
# Build output
build/
+43
View File
@@ -0,0 +1,43 @@
FROM alpine:3.22.4 AS builder
RUN apk add --no-cache \
ca-certificates \
gcc \
musl-dev \
make \
pkgconf \
curl-dev \
curl-static \
cjson-dev \
cjson-static \
zstd-dev \
zstd-static \
openssl-dev \
openssl-libs-static \
nghttp2-dev \
nghttp2-static \
zlib-dev \
zlib-static \
brotli-dev \
brotli-static \
c-ares-dev \
libidn2-dev \
libidn2-static \
libpsl-dev \
libpsl-static \
libunistring-dev \
libunistring-static
WORKDIR /build
COPY src/ src/
COPY Makefile .
RUN make STATIC=1
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /etc/ssl/cert.pem /etc/ssl/cert.pem
COPY --from=builder /build/build/dns-keeper /dns-keeper
ENTRYPOINT ["/dns-keeper"]
+79 -2
View File
@@ -1,3 +1,80 @@
# dns-keeper # DNS Keeper
A security-hardened dynamic DNS updater with self-verification and multi-API IP consensus. A small but robust program to keep DNS records updated with your public _(external)_ IPv4 address.
Mostly like DDNS but using API driven updates.
Currently only gandi's liveDNS API is supported.
## How it works
Every `POLL_INTERVAL` seconds, DNS Keeper will check if the IPv4 in the domain(s) is still
valid by seeing if `DNS_KEEPER_RECORD`.`DNS_KEEPER_DOMAIN` still points to itself.
> [!NOTE]
> On startup DNS Keeper starts a detached thread with a UDP socket on which it answers
> a random set of challenge bytes.
> DNS Keeper will send an empty data-frame to its own socket using
> the DNS resolved IPv4 address to see if it gets a response with the correct challenge bytes.
> The challenge bytes are changed before every self-check.
If DNS Keeper fails to get a response from itself for [3](./src/selfcheck.h#6)
times it will check it's cached IP against the response of multiple external
[services](./src/ipcheck.c#8) that return your public IPv4.
If the returned public IP is the same as the cached one,
DNS Keepr will see that as drift.
If this happens too much it will revalidate its own cache against the self-record.
If the returned public IP is different from the cached one,
DNS Keeper will check the new IP using its own socket again,
this time without DNS.
If it proves to be correct it will update all A record of the specified `DOMAINS`
who's value match the old IP to the new IP.
The chached IP is only updated after the self-record is updated correctly.
> [!NOTE]
> DNS Keeper enforces a strict majority amongst the services.
> If a strict majority cannot be reached or not enough services
> were able to respond, it will take no action and reset the failed counter.
> This is to prevent any issues should external services be compromised
> and to avoid spamming these services in case something else is wrong.
## Environment Variables
| Field | Descript | Default |
| ------------------- | ---------------------------------------------------------------------------------------- | ------- |
| `GANDI_API_TOKEN` | Gandi API Token with permission to "See and renew domain names". | |
| `DOMAINS` | The domains _(eg. "example.noreal,demo.notreal")_ to keep the A records up to date of. | |
| `DNS_KEEPER_PORT` | The port on which DNS Keepr runs its self-check UDP Socket. | 65535 |
| `DNS_KEEPER_DOMAIN` | The domain on which the record pointing to dns-keeper lives. (eg. example.notreal). | |
| `DNS_KEEPER_RECORD` | DNS record that points to your public IP/DNS-Keeper (eg. _dnsk). | |
| `POLL_INTERVAL` | The interval (in seconds) at which DNS Keeper should check if it can still reach itself. | 5 |
## Setup
You can the binary using the dynamically linked or static using make.
```sh
# Dynamically linked
make
# Static:
make STATIC=1
```
The dynamically linked binary is smaller but requires dependencies:
libcurl, libcjson and pthread to be on the target machine.
The static binary is slightly bigger but is fully self contained.
### Docker Image
The [Dockerfile](./Dockerfile) in this repo builds a minimal image with just the static binary and CA certificates.
```sh
docker build -t dns-keeper .
```
> [!TIP]
> Because the image is just the c binary with ca certs, there is no shell to exec into.
> `docker exec` will not work for debugging.
+87
View File
@@ -0,0 +1,87 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <curl/curl.h>
#include "ipcheck.h"
#include "log.h"
static const char *SERVICES[] = {
"https://ipv4.icanhazip.com",
"https://api4.ipify.org",
"https://ipv4.wtfismyip.com/text",
"https://v4.ident.me",
"https://ipv4.am.i.mullvad.net/ip",
"https://4.ident.me",
"https://4.tnedi.me",
};
#define SERVICE_COUNT (int)(sizeof(SERVICES) / sizeof(SERVICES[0]))
typedef struct {
char buf[IPCHECK_MAX_IP_LEN];
size_t len;
} IPBuf;
static size_t write_cb(void *data, size_t size, size_t nmemb, void *userp) {
IPBuf *out = (IPBuf *)userp;
size_t total = size * nmemb;
if (out->len + total >= IPCHECK_MAX_IP_LEN) return 0;
memcpy(out->buf + out->len, data, total);
out->len += total;
return total;
}
int ipcheck_get_public_ip(char *out_ip) {
char results[SERVICE_COUNT][IPCHECK_MAX_IP_LEN];
int valid = 0;
CURL *curl = curl_easy_init();
if (!curl) return -1;
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, IPCHECK_TIMEOUT_SECS);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
for (int i = 0; i < SERVICE_COUNT; i++) {
IPBuf buf = { .buf = {0}, .len = 0 };
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
curl_easy_setopt(curl, CURLOPT_URL, SERVICES[i]);
CURLcode res = curl_easy_perform(curl);
if (res != CURLE_OK) continue;
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
if (http_code != 200) continue;
buf.buf[buf.len] = '\0';
if (sscanf(buf.buf, IPCHECK_IP_SCAN_FMT, results[valid]) != 1) continue;
valid++;
}
curl_easy_cleanup(curl);
if (valid < IPCHECK_MIN_RESPONDERS) {
log_warn("ipcheck: only %d/%d services responded, need at least %d",
valid, SERVICE_COUNT, IPCHECK_MIN_RESPONDERS);
return -1;
}
for (int i = 0; i < valid; i++) {
int count = 0;
for (int j = 0; j < valid; j++) {
if (strcmp(results[i], results[j]) == 0) count++;
}
if (count * 2 > valid) {
strncpy(out_ip, results[i], IPCHECK_MAX_IP_LEN - 1);
out_ip[IPCHECK_MAX_IP_LEN - 1] = '\0';
return 0;
}
}
log_warn("ipcheck: no strict majority among %d/%d responders",
valid, SERVICE_COUNT);
return -2;
}
+20
View File
@@ -0,0 +1,20 @@
#ifndef IPCHECK_H
#define IPCHECK_H
#define IPCHECK_MIN_RESPONDERS 3
#define IPCHECK_TIMEOUT_SECS 10L
#define IPCHECK_MAX_IP_LEN 46
#define IPCHECK_IP_SCAN_FMT "%45s"
/*
* Tries to get the public IP by seeing if a strict majority
* of services agree. At least a minimum amount of services
* need to respond.
*
* Returns 0 on success.
* Returns -1 if fewer than IPCHECK_MIN_RESPONDERS answered.
* Returns -2 if no strict majority was reached (tie or split).
*/
int ipcheck_get_public_ip(char *out_ip);
#endif /* IPCHECK_H */
+143
View File
@@ -0,0 +1,143 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <curl/curl.h>
#include "config.h"
#include "gandi.h"
#include "ipcheck.h"
#include "log.h"
#include "selfcheck.h"
static volatile sig_atomic_t g_running = 1;
static void handle_signal(int sig) {
(void)sig;
g_running = 0;
}
int main(void) {
struct sigaction sa = {0};
sa.sa_handler = handle_signal;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
Config cfg = {0};
if (config_load(&cfg) != 0) return EXIT_FAILURE;
log_init();
curl_global_init(CURL_GLOBAL_DEFAULT);
int ret = EXIT_SUCCESS;
if (gandi_init(cfg.api_token) != 0) {
log_error("gandi_init failed");
ret = EXIT_FAILURE;
goto cleanup;
}
char cached_ip[GANDI_MAX_IP_LEN] = {0};
if (gandi_get_self_ip(cfg.dns_keeper_domain, cfg.dns_keeper_record, cached_ip) != 0) {
log_error("Failed to fetch initial IP from self record %s.%s",
cfg.dns_keeper_record, cfg.dns_keeper_domain);
ret = EXIT_FAILURE;
goto cleanup;
}
log_info("Self record points to %s", cached_ip);
char self_fqdn[256];
snprintf(self_fqdn, sizeof(self_fqdn), "%s.%s", cfg.dns_keeper_record, cfg.dns_keeper_domain);
if (selfcheck_start_listener(cfg.dns_keeper_port) != 0) {
log_error("Failed to start self-check listener on port %d", cfg.dns_keeper_port);
ret = EXIT_FAILURE;
goto cleanup;
}
log_info("Listening on UDP port %d", cfg.dns_keeper_port);
int fail_count = 0; /* consecutive self-check failures */
int drift_count = 0; /* when external IP matches cache but self-check fails */
while (g_running) {
if (selfcheck_domain(self_fqdn, cfg.dns_keeper_port) == 0) {
fail_count = 0;
drift_count = 0;
sleep(cfg.poll_interval);
continue;
}
fail_count++;
if (fail_count < SELFCHECK_FAIL_THRESHOLD) {
sleep(cfg.poll_interval);
continue;
}
fail_count = 0;
char public_ip[IPCHECK_MAX_IP_LEN] = {0};
if (ipcheck_get_public_ip(public_ip) != 0) {
log_warn("ipcheck: failed to determine public IP, skipping cycle");
sleep(cfg.poll_interval);
continue;
}
if (strcmp(public_ip, cached_ip) == 0) {
drift_count++;
if (drift_count >= SELFCHECK_DRIFT_THRESHOLD) {
char gandi_ip[GANDI_MAX_IP_LEN] = {0};
if (gandi_get_self_ip(cfg.dns_keeper_domain, cfg.dns_keeper_record, gandi_ip) == 0
&& strcmp(gandi_ip, cached_ip) != 0) {
log_info("Cache drift: cached %s but Gandi has %s, resyncing",
cached_ip, gandi_ip);
strncpy(cached_ip, gandi_ip, GANDI_MAX_IP_LEN - 1);
cached_ip[GANDI_MAX_IP_LEN - 1] = '\0';
}
drift_count = 0;
}
sleep(SELFCHECK_COOLDOWN_S);
continue;
}
drift_count = 0;
log_info("IP change detected: %s -> %s", cached_ip, public_ip);
if (selfcheck_ip_str(public_ip, cfg.dns_keeper_port) != 0) {
log_warn("Direct check of %s failed, deferring update", public_ip);
sleep(SELFCHECK_COOLDOWN_S);
continue;
}
int failed_updates = 0;
for (int i = 0; i < cfg.domain_count; i++) {
if (strcmp(cfg.domains[i], cfg.dns_keeper_domain) == 0) continue;
int gret = gandi_update_domains(cfg.domains[i], cached_ip, public_ip);
if (gret == -3) {
log_info("gandi: [%s] no matching A records for %s, skipping",
cfg.domains[i], cached_ip);
} else if (gret != 0) {
failed_updates = 1;
}
}
int sret = gandi_update_record(cfg.dns_keeper_domain, cfg.dns_keeper_record,
cached_ip, public_ip);
if (sret == 0) {
strncpy(cached_ip, public_ip, GANDI_MAX_IP_LEN - 1);
cached_ip[GANDI_MAX_IP_LEN - 1] = '\0';
} else {
log_error("Failed to update self record, cached IP not advanced");
failed_updates = 1;
}
if (failed_updates) log_error("One or more updates failed this cycle");
sleep(SELFCHECK_COOLDOWN_S);
}
log_info("Shutting down");
cleanup:
curl_global_cleanup();
config_free(&cfg);
return ret;
}
+140
View File
@@ -0,0 +1,140 @@
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <stdint.h>
#include <pthread.h>
#include <sys/socket.h>
#include <poll.h>
#include <sys/random.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include "log.h"
#include "selfcheck.h"
static uint8_t s_challenge[SELFCHECK_CHALLENGE_LEN];
static pthread_mutex_t s_challenge_mutex = PTHREAD_MUTEX_INITIALIZER;
static int rand_bytes(uint8_t *buf, size_t len) {
return (getrandom(buf, len, 0) == (ssize_t)len) ? 0 : -1;
}
static void *listener_thread(void *arg) {
int port = *(int *)arg;
free(arg);
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { log_error("selfcheck listener: socket %s", strerror(errno)); return NULL; }
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)port);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
log_error("selfcheck listener: bind %s", strerror(errno));
close(fd);
return NULL;
}
uint8_t discard[256];
struct sockaddr_in sender;
socklen_t sender_len;
for (;;) {
sender_len = sizeof(sender);
ssize_t n = recvfrom(fd, discard, sizeof(discard), 0,
(struct sockaddr *)&sender, &sender_len);
if (n < 0) continue;
uint8_t response[SELFCHECK_CHALLENGE_LEN];
pthread_mutex_lock(&s_challenge_mutex);
memcpy(response, s_challenge, SELFCHECK_CHALLENGE_LEN);
pthread_mutex_unlock(&s_challenge_mutex);
sendto(fd, response, SELFCHECK_CHALLENGE_LEN, 0,
(struct sockaddr *)&sender, sender_len);
}
close(fd);
return NULL;
}
static int check_ip(struct in_addr addr, int port) {
uint8_t challenge[SELFCHECK_CHALLENGE_LEN];
if (rand_bytes(challenge, SELFCHECK_CHALLENGE_LEN) != 0) return -1;
pthread_mutex_lock(&s_challenge_mutex);
memcpy(s_challenge, challenge, SELFCHECK_CHALLENGE_LEN);
pthread_mutex_unlock(&s_challenge_mutex);
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) return -1;
struct sockaddr_in dst = {0};
dst.sin_family = AF_INET;
dst.sin_port = htons((uint16_t)port);
dst.sin_addr = addr;
uint8_t payload = 0;
if (sendto(fd, &payload, sizeof(payload), 0,
(struct sockaddr *)&dst, sizeof(dst)) < 0) {
close(fd); return -1;
}
struct pollfd pfd = { .fd = fd, .events = POLLIN };
if (poll(&pfd, 1, SELFCHECK_RECV_TIMEOUT_S * 1000) <= 0) { close(fd); return -1; }
uint8_t reply[SELFCHECK_CHALLENGE_LEN + 1];
ssize_t n = recvfrom(fd, reply, sizeof(reply), 0, NULL, NULL);
close(fd);
if (n != SELFCHECK_CHALLENGE_LEN) return -2;
return (memcmp(challenge, reply, SELFCHECK_CHALLENGE_LEN) == 0) ? 0 : -2;
}
int selfcheck_start_listener(int port) {
if (rand_bytes(s_challenge, SELFCHECK_CHALLENGE_LEN) != 0) {
log_error("selfcheck: failed get random bytes for challenge");
return -1;
}
int *pport = malloc(sizeof(int));
if (!pport) return -1;
*pport = port;
pthread_t tid;
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
int r = pthread_create(&tid, &attr, listener_thread, pport);
pthread_attr_destroy(&attr);
if (r != 0) { free(pport); return -1; }
return 0;
}
int selfcheck_domain(const char *domain, int port) {
struct addrinfo hints = {0}, *res = NULL;
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_DGRAM;
if (getaddrinfo(domain, NULL, &hints, &res) != 0 || !res) {
if (res) freeaddrinfo(res);
return -1;
}
struct in_addr addr = ((struct sockaddr_in *)res->ai_addr)->sin_addr;
freeaddrinfo(res);
return check_ip(addr, port);
}
int selfcheck_ip_str(const char *ip, int port) {
struct in_addr addr;
if (inet_pton(AF_INET, ip, &addr) != 1) return -1;
return check_ip(addr, port);
}
+39
View File
@@ -0,0 +1,39 @@
#ifndef SELFCHECK_H
#define SELFCHECK_H
#define SELFCHECK_CHALLENGE_LEN 16
#define SELFCHECK_RECV_TIMEOUT_S 2
#define SELFCHECK_FAIL_THRESHOLD 3
#define SELFCHECK_DRIFT_THRESHOLD 10
#define SELFCHECK_COOLDOWN_S 60
/*
* Init challenge buffer and start UDP socket listener thread.
* The socket discards any datagram and always
* responsds with the challenge bytes.
*
* Returns 0 on success.
* Returns -1 on failure.
*/
int selfcheck_start_listener(int port);
/*
* Checks if the random challenge bytes are received
* when sending an empty datagram to the domain and port.
*
* Returns 0 if the response matches.
* Returns -1 on DNS failure, send failure, or timeout.
* Returns -2 if the response did not match.
*/
int selfcheck_domain(const char *domain, int port);
/*
* Same as selfcheck_domain but sends directly to `ip` without DNS.
*
* Returns 0 if the response matches.
* Returns -1 on send failure or timeout.
* Returns -2 if the response did not match.
*/
int selfcheck_ip_str(const char *ip, int port);
#endif /* SELFCHECK_H */