Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

28 changed files with 1023 additions and 2819 deletions

View file

@ -1,2 +1,2 @@
[build] [build]
rustflags = ["-Clink-arg=-fuse-ld=mold"] rustflags = ["-Clink-arg=-fuse-ld=mold", "-Zthreads=16"]

View file

@ -1,14 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
[*.{nix,toml,json}]
indent_style = space
indent_size = 2
[*.rs]
indent_style = space
indent_size = 4

View file

@ -1,41 +0,0 @@
on: [push]
jobs:
check-renovaterc:
runs-on: nixos
steps:
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Validate renovaterc
run: |
nix --version
nix shell nixpkgs#renovate --command renovate-config-validator
build:
runs-on: nixos
steps:
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Build Package
run: |
nix --version
nix build --print-build-logs .#
test:
needs: build # we use the built binaries in the checks
runs-on: nixos
steps:
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Run tests
run: |
nix --version
nix-fast-build --max-jobs 2 --no-nom --skip-cached --no-link \
--flake ".#checks.$(nix eval --raw --impure --expr builtins.currentSystem)"
report-size:
runs-on: nixos
needs: build
steps:
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: nix --version
- name: Generate size report
uses: "https://git.salame.cl/jalil/nix-flake-outputs-size@5c40a31e3e2ed0ea28f8ba68deca41d05fdf2e71" # main
with:
comment-on-pr: ${{ github.ref_name != 'main' }}
generate-artifact: ${{ github.ref_name == 'main' }}
do-comparison: true
job-name: report-size

View file

@ -1,44 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"assignees": [
"jalil"
],
"automerge": true,
"automergeStrategy": "auto",
"automergeType": "pr",
"commitBodyTable": true,
"dependencyDashboard": true,
"extends": [
"config:best-practices"
],
"prCreation": "immediate",
"cargo": {
"commitMessageTopic": "Rust crate {{depName}}",
"fileMatch": [
"(^|/)Cargo\\.toml$"
],
"versioning": "cargo",
"enabled": true
},
"nix": {
"fileMatch": [
"(^|/)flake\\.nix$"
],
"commitMessageTopic": "nixpkgs",
"commitMessageExtra": "to {{newValue}}",
"enabled": true
},
"lockFileMaintenance": {
"enabled": true,
"recreateWhen": "always",
"rebaseWhen": "behind-base-branch",
"branchTopic": "lock-file-maintenance",
"commitMessageAction": "Lock file maintenance",
"schedule": [
"* 22 * * *"
]
},
"automergeSchedule": [
"* 23 * * *"
]
}

View file

@ -1,139 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.3.6] - 2025-01-26
### 🚀 Features
- *(webnsupdate)* Allow running in IPv4/6 only mode
- *(module)* Add option for setting --ip-type
- *(flake)* Add tests for new allowedIPVersion option
## [0.3.5] - 2025-01-23
### 🚀 Features
- *(renovate)* Enable lockFileMaintenance
- *(webnsupdate)* Add handling for multiple IPs
- Tune compilation for size
- *(tests)* Add nginx integration test
### 🐛 Bug Fixes
- *(flake)* Switch to github ref
- *(renovate)* Switch automergeStrategy to auto
- *(ci)* Remove update workflow
- *(typos)* Typos caught more typos :3
- *(renovate)* Branch creation before automerge
- *(renovaterc)* Invalid cron syntax
- *(deps)* Update rust crate clap to v4.5.24
- *(deps)* Update rust crate tokio to v1.43.0
- *(deps)* Update rust crate clap to v4.5.25
- *(deps)* Update rust crate clap to v4.5.26
- *(flake)* Switch overlay to callPackage
- *(deps)* Update rust crate clap to v4.5.27
- *(deps)* Update rust crate axum to v0.8.2
- *(module)* Test both IPv4 and IPv6
### 🚜 Refactor
- Setup renovate to manage dependencies
### ⚙️ Miscellaneous Tasks
- Update to axum 0.8
- Parallelize checks
## [0.3.4] - 2024-12-26
### 🐛 Bug Fixes
- *(main)* Add more logging and default to info
## [0.3.3] - 2024-12-22
### 🚀 Features
- *(ci)* Generate package size report
- Add git-cliff to generate changelogs
### 🐛 Bug Fixes
- *(webnsupdate)* Reduce binary size
- *(ci)* Remove tea
### ⚙️ Miscellaneous Tasks
- *(flake.lock)* Update inputs
- Cargo update
- Generate base changelog
## [0.3.2] - 2024-11-23
### 🚀 Features
- *(ci)* Check depends on build
- Upgrade clap_verbosity_flag
- Replace axum-auth with tower_http
- Release new version
### 🐛 Bug Fixes
- *(clippy)* Enable more lints and fix issues
### 🚜 Refactor
- Reorganize main.rs
### ⚙️ Miscellaneous Tasks
- Cargo update
- Update flake inputs
## [0.3.1] - 2024-10-28
### 🐛 Bug Fixes
- Overlay was broken T-T
### ⚙️ Miscellaneous Tasks
- Next dev version
## [0.3.0] - 2024-10-28
### 🚀 Features
- *(ci)* Auto-update rust deps
- Refactor and add ip saving
- Add -v verbosity flag
- Use treefmt-nix and split up flake.nix
- Add NixOS VM tests
- Switch to crane
### 🐛 Bug Fixes
- *(fmt)* Use nixfmt-rfc-style
- *(default.nix)* Small issues here and there
- *(ci)* Do not use a name when logging in
### 🚜 Refactor
- *(flake)* Use flake-parts
### ⚙️ Miscellaneous Tasks
- Updarte deps
- *(flake.lock)* Update inputs
- Cargo update
- Cargo update
- Cargo update
## [0.2.0] - 2024-06-02
### 💼 Other
- Init at version 0.1.0
<!-- generated by git-cliff -->

667
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,49 +1,31 @@
cargo-features = ["codegen-backend"]
[package] [package]
description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate" description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate"
name = "webnsupdate" name = "webnsupdate"
version = "0.3.6" version = "0.2.0"
edition = "2021" edition = "2021"
license = "MIT"
license-file = "LICENSE"
readme = "README.md"
keywords = ["dns", "dyndns", "dynamic-ip"]
categories = ["networking", "dns", "dyndns"]
repository = "https://github.com/jalil-salame/webnsupdate"
[lints.clippy]
cargo = { level = "warn", priority = -2 }
multiple_crate_versions = "allow"
pedantic = { level = "warn", priority = -1 }
[dependencies] [dependencies]
axum = "0.8" axum = "0.7.5"
axum-client-ip = "1.0" axum-client-ip = "0.6.0"
base64 = "0.22" base64 = "0.22.1"
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4.5.4", features = ["derive", "env"] }
clap-verbosity-flag = { version = "3", default-features = false, features = [ http = "1.1.0"
"tracing", insta = "1.38.0"
] } miette = { version = "7.2.0", features = ["fancy"] }
http = "1" ring = { version = "0.17.8", features = ["std"] }
humantime = "2.2.0" tracing = "0.1.40"
miette = { version = "7", features = ["fancy"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
ring = { version = "0.17", features = ["std"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["macros", "rt", "process", "io-util"] }
tower-http = { version = "0.6", features = ["validate-request"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies] [dependencies.axum-auth]
insta = { version = "=1.42.2", features = ["json"] } version = "0.7.0"
default-features = false
features = ["auth-basic"]
[profile.release] [dependencies.tokio]
opt-level = "s" version = "1.37.0"
panic = "abort" features = ["macros", "rt", "process", "io-util"]
lto = true
strip = true
codegen-units = 1
[profile.dev] [profile.dev]
debug = 0 debug = 0
codegen-backend = "cranelift"

View file

@ -1,85 +0,0 @@
# git-cliff ~ default configuration file
# https://git-cliff.org/docs/configuration
#
# Lines starting with "#" are comments.
# Configuration options are organized into tables and keys.
# See documentation for more information on available options.
[changelog]
# template for the changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
# template for the changelog footer
footer = """
<!-- generated by git-cliff -->
"""
# remove the leading and trailing s
trim = true
# postprocessors
postprocessors = [
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
]
# render body even when there are no releases to process
# render_always = true
# output file path
# output = "test.md"
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
# Replace issue numbers
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit with https://github.com/crate-ci/typos
# If the spelling is incorrect, it will be automatically fixed.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore: bump version", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
{ message = ".*", group = "<!-- 10 -->💼 Other" },
]
# filter out the commits that are not matched by commit parsers
filter_commits = false
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"

View file

@ -1,37 +1,25 @@
{ {
pkgs ? lib,
(builtins.getFlake (builtins.toString ./.)).inputs.nixpkgs.legacyPackages.${builtins.currentSystem}, rustPlatform,
lib ? pkgs.lib, }: let
crane ? (builtins.getFlake (builtins.toString ./.)).inputs.crane, readToml = path: builtins.fromTOML (builtins.readFile path);
pkgSrc ? ./., cargoToml = readToml ./Cargo.toml;
mold ? pkgs.mold, pname = cargoToml.package.name;
}: inherit (cargoToml.package) version description;
let in
craneLib = crane.mkLib pkgs; rustPlatform.buildRustPackage {
src = craneLib.cleanCargoSource pkgSrc; inherit pname version;
src = builtins.path {
commonArgs = { path = ./.;
inherit src; name = "${pname}-source";
strictDeps = true; };
cargoLock.lockFile = ./Cargo.lock;
doCheck = false; # tests will be run in the `checks` derivation useNextest = true;
NEXTEST_HIDE_PROGRESS_BAR = 1;
NEXTEST_FAILURE_OUTPUT = "immediate-final";
nativeBuildInputs = [ mold ];
meta = { meta = {
inherit description;
license = lib.licenses.mit; license = lib.licenses.mit;
homepage = "https://github.com/jalil-salame/webnsupdate"; homepage = "https://github.com/jalil-salame/webnsupdate";
mainProgram = "webnsupdate"; mainProgram = "webnsupdate";
}; };
}; }
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
in
craneLib.buildPackage (
lib.mergeAttrsList [
commonArgs
{ inherit cargoArtifacts; }
]
)

View file

@ -1,41 +0,0 @@
{ inputs, ... }:
{
imports = [
inputs.treefmt-nix.flakeModule
./package.nix
./tests.nix
];
flake.nixosModules =
let
webnsupdate = ../module.nix;
in
{
default = webnsupdate;
inherit webnsupdate;
};
perSystem =
{ pkgs, ... }:
{
# Setup formatters
treefmt = {
projectRootFile = "flake.nix";
programs = {
nixfmt.enable = true;
rustfmt.enable = true;
statix.enable = true;
typos.enable = true;
};
};
devShells.default = pkgs.mkShellNoCC {
packages = with pkgs; [
cargo-insta
cargo-udeps
mold
git-cliff
];
};
};
}

View file

@ -1,60 +0,0 @@
{ inputs, ... }:
{
flake.overlays.default = final: prev: {
webnsupdate = prev.callPackage ../default.nix {
inherit (inputs) crane;
pkgSrc = inputs.self;
};
};
perSystem =
{ pkgs, lib, ... }:
let
craneLib = inputs.crane.mkLib pkgs;
src = craneLib.cleanCargoSource inputs.self;
commonArgs = {
inherit src;
strictDeps = true;
doCheck = false; # tests will be run in the `checks` derivation
NEXTEST_HIDE_PROGRESS_BAR = 1;
NEXTEST_FAILURE_OUTPUT = "immediate-final";
nativeBuildInputs = [ pkgs.mold ];
meta = {
license = lib.licenses.mit;
homepage = "https://github.com/jalil-salame/webnsupdate";
mainProgram = "webnsupdate";
};
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
withArtifacts = lib.mergeAttrsList [
commonArgs
{ inherit cargoArtifacts; }
];
webnsupdate = pkgs.callPackage ../default.nix {
inherit (inputs) crane;
pkgSrc = inputs.self;
};
in
{
checks = {
nextest = craneLib.cargoNextest withArtifacts;
clippy = craneLib.cargoClippy (
lib.mergeAttrsList [
withArtifacts
{ cargoClippyExtraArgs = "--all-targets -- --deny warnings"; }
]
);
};
packages = {
inherit webnsupdate;
inherit (pkgs) git-cliff;
default = webnsupdate;
};
};
}

View file

@ -1,343 +0,0 @@
{ self, ... }:
{
perSystem =
{ pkgs, self', ... }:
{
checks =
let
testDomain = "webnstest.example";
lastIPPath = "/var/lib/webnsupdate/last-ip.json";
zoneFile = pkgs.writeText "${testDomain}.zoneinfo" ''
$TTL 600 ; 10 minutes
$ORIGIN ${testDomain}.
@ IN SOA ns1.${testDomain}. admin.${testDomain}. (
1 ; serial
6h ; refresh
1h ; retry
1w ; expire
1d) ; negative caching TTL
IN NS ns1.${testDomain}.
@ IN A 127.0.0.1
ns1 IN A 127.0.0.1
nsupdate IN A 127.0.0.1
@ IN AAAA ::1
ns1 IN AAAA ::1
nsupdate IN AAAA ::1
'';
bindDynamicZone =
{ config, ... }:
let
bindCfg = config.services.bind;
bindData = bindCfg.directory;
dynamicZonesDir = "${bindData}/zones";
in
{
services.bind.zones.${testDomain} = {
master = true;
file = "${dynamicZonesDir}/${testDomain}";
extraConfig = ''
allow-update { key rndc-key; };
'';
};
systemd.services.bind.preStart = ''
# shellcheck disable=SC2211,SC1127
rm -f ${dynamicZonesDir}/* # reset dynamic zones
# create a dynamic zones dir
mkdir -m 0755 -p ${dynamicZonesDir}
# copy dynamic zone's file to the dynamic zones dir
cp ${zoneFile} ${dynamicZonesDir}/${testDomain}
'';
};
webnsupdate-ipv4-machine =
{ lib, ... }:
{
imports = [
bindDynamicZone
self.nixosModules.webnsupdate
];
config = {
environment.systemPackages = [
pkgs.dig
pkgs.curl
];
services = {
bind.enable = true;
webnsupdate = {
enable = true;
package = self'.packages.webnsupdate;
extraArgs = [ "-vvv" ]; # debug messages
settings = {
address = lib.mkDefault "127.0.0.1:5353";
key_file = "/etc/bind/rndc.key";
password_file = pkgs.writeText "webnsupdate.pass" "FQoNmuU1BKfg8qsU96F6bK5ykp2b0SLe3ZpB3nbtfZA"; # test:test
ip_source = lib.mkDefault "ConnectInfo";
records = [
"test1.${testDomain}."
"test2.${testDomain}."
"test3.${testDomain}."
];
};
};
};
};
};
webnsupdate-ipv6-machine = {
imports = [
webnsupdate-ipv4-machine
];
config.services.webnsupdate.settings.address = "[::1]:5353";
};
webnsupdate-nginx-machine =
{ lib, config, ... }:
{
imports = [
webnsupdate-ipv4-machine
];
config.services = {
# Use default IP Source
webnsupdate.settings.ip_source = "RightmostXForwardedFor";
nginx = {
enable = true;
recommendedProxySettings = true;
virtualHosts.webnsupdate.locations."/".proxyPass =
"http://${config.services.webnsupdate.settings.address}";
};
};
};
webnsupdate-ipv4-only-machine = {
imports = [ webnsupdate-nginx-machine ];
config.services.webnsupdate.settings.ip_type = "Ipv4Only";
};
webnsupdate-ipv6-only-machine = {
imports = [ webnsupdate-nginx-machine ];
config.services.webnsupdate.settings.ip_type = "Ipv6Only";
};
# "A" for IPv4, "AAAA" for IPv6, "ANY" for any
testTemplate =
{
ipv4 ? false,
ipv6 ? false,
nginx ? false,
exclusive ? false,
}:
if exclusive && (ipv4 == ipv6) then
builtins.throw "exclusive means one of ipv4 or ipv6 must be set, but not both"
else
''
IPV4: bool = ${if ipv4 then "True" else "False"}
IPV6: bool = ${if ipv6 then "True" else "False"}
NGINX: bool = ${if nginx then "True" else "False"}
EXCLUSIVE: bool = ${if exclusive then "True" else "False"}
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
CURL: str = "curl --fail --no-progress-meter --show-error"
machine.start(allow_reboot=True)
machine.wait_for_unit("bind.service")
machine.wait_for_unit("webnsupdate.service")
STATIC_DOMAINS: list[str] = ["${testDomain}", "ns1.${testDomain}", "nsupdate.${testDomain}"]
DYNAMIC_DOMAINS: list[str] = ["test1.${testDomain}", "test2.${testDomain}", "test3.${testDomain}"]
def dig_cmd(domain: str, record: str, ip: str | None) -> tuple[str, str]:
match_ip = "" if ip is None else f"\\s\\+600\\s\\+IN\\s\\+{record}\\s\\+{ip}$"
return f"dig @localhost {record} {domain} +noall +answer", f"grep '^{domain}.{match_ip}'"
def curl_cmd(domain: str, identity: str, path: str, query: dict[str, str]) -> str:
from urllib.parse import urlencode
q= f"?{urlencode(query)}" if query else ""
return f"{CURL} -u {identity} -X GET 'http://{domain}{"" if NGINX else ":5353"}/{path}{q}'"
def domain_available(domain: str, record: str, ip: str | None=None):
dig, grep = dig_cmd(domain, record, ip)
rc, output = machine.execute(dig)
print(f"{dig}[{rc}]: {output}")
machine.succeed(f"{dig} | {grep}")
def domain_missing(domain: str, record: str, ip: str | None=None):
dig, grep = dig_cmd(domain, record, ip)
rc, output = machine.execute(dig)
print(f"{dig}[{rc}]: {output}")
machine.fail(f"{dig} | {grep}")
def update_records(domain: str="localhost", /, *, path: str="update", **kwargs):
machine.succeed(curl_cmd(domain, "test:test", path, kwargs))
machine.succeed("cat ${lastIPPath}")
def update_records_fail(domain: str="localhost", /, *, identity: str="test:test", path: str="update", **kwargs):
machine.fail(curl_cmd(domain, identity, path, kwargs))
machine.fail("cat ${lastIPPath}")
def invalid_update(domain: str="localhost"):
update_records_fail(domain, identity="bad_user:test")
update_records_fail(domain, identity="test:bad_pass")
# Tests
with subtest("static DNS records are available"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
for domain in STATIC_DOMAINS:
domain_available(domain, "A", "127.0.0.1") # IPv4
domain_available(domain, "AAAA", "::1") # IPv6
with subtest("dynamic DNS records are missing"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
for domain in DYNAMIC_DOMAINS:
domain_missing(domain, "A") # IPv4
domain_missing(domain, "AAAA") # IPv6
with subtest("invalid auth fails to update records"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
invalid_update()
for domain in DYNAMIC_DOMAINS:
domain_missing(domain, "A") # IPv4
domain_missing(domain, "AAAA") # IPv6
if EXCLUSIVE:
with subtest("exclusive IP version fails to update with invalid version"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
if IPV6:
update_records_fail("127.0.0.1")
if IPV4:
update_records_fail("[::1]")
with subtest("valid auth updates records"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
if IPV4:
update_records("127.0.0.1")
if IPV6:
update_records("[::1]")
for domain in DYNAMIC_DOMAINS:
if IPV4:
domain_available(domain, "A", "127.0.0.1")
elif IPV6 and EXCLUSIVE:
domain_missing(domain, "A")
if IPV6:
domain_available(domain, "AAAA", "::1")
elif IPV4 and EXCLUSIVE:
domain_missing(domain, "AAAA")
with subtest("valid auth fritzbox compatible updates records"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
if IPV4 and IPV6:
update_records("127.0.0.1", domain="test", ipv4="1.2.3.4", ipv6="::1234")
elif IPV4:
update_records("127.0.0.1", ipv4="1.2.3.4", ipv6="")
elif IPV6:
update_records("[::1]", ipv4="", ipv6="::1234")
for domain in DYNAMIC_DOMAINS:
if IPV4:
domain_available(domain, "A", "1.2.3.4")
elif IPV6 and EXCLUSIVE:
domain_missing(domain, "A")
if IPV6:
domain_available(domain, "AAAA", "::1234")
elif IPV4 and EXCLUSIVE:
domain_missing(domain, "AAAA")
with subtest("valid auth replaces records"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
if IPV4:
update_records("127.0.0.1")
if IPV6:
update_records("[::1]")
for domain in DYNAMIC_DOMAINS:
if IPV4:
domain_available(domain, "A", "127.0.0.1")
elif IPV6 and EXCLUSIVE:
domain_missing(domain, "A")
if IPV6:
domain_available(domain, "AAAA", "::1")
elif IPV4 and EXCLUSIVE:
domain_missing(domain, "AAAA")
machine.reboot()
machine.succeed("cat ${lastIPPath}")
machine.wait_for_unit("webnsupdate.service")
machine.succeed("cat ${lastIPPath}")
with subtest("static DNS records are available after reboot"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
for domain in STATIC_DOMAINS:
domain_available(domain, "A", "127.0.0.1") # IPv4
domain_available(domain, "AAAA", "::1") # IPv6
with subtest("dynamic DNS records are available after reboot"):
print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}")
for domain in DYNAMIC_DOMAINS:
if IPV4:
domain_available(domain, "A", "127.0.0.1")
elif IPV6 and EXCLUSIVE:
domain_missing(domain, "A")
if IPV6:
domain_available(domain, "AAAA", "::1")
elif IPV4 and EXCLUSIVE:
domain_missing(domain, "AAAA")
'';
in
{
module-ipv4-test = pkgs.testers.nixosTest {
name = "webnsupdate-ipv4-module";
nodes.machine = webnsupdate-ipv4-machine;
testScript = testTemplate { ipv4 = true; };
};
module-ipv6-test = pkgs.testers.nixosTest {
name = "webnsupdate-ipv6-module";
nodes.machine = webnsupdate-ipv6-machine;
testScript = testTemplate { ipv6 = true; };
};
module-nginx-test = pkgs.testers.nixosTest {
name = "webnsupdate-nginx-module";
nodes.machine = webnsupdate-nginx-machine;
testScript = testTemplate {
ipv4 = true;
ipv6 = true;
nginx = true;
};
};
module-ipv4-only-test = pkgs.testers.nixosTest {
name = "webnsupdate-ipv4-only-module";
nodes.machine = webnsupdate-ipv4-only-machine;
testScript = testTemplate {
ipv4 = true;
nginx = true;
exclusive = true;
};
};
module-ipv6-only-test = pkgs.testers.nixosTest {
name = "webnsupdate-ipv6-only-module";
nodes.machine = webnsupdate-ipv6-only-machine;
testScript = testTemplate {
ipv6 = true;
nginx = true;
exclusive = true;
};
};
};
};
}

71
flake.lock generated
View file

@ -1,63 +1,24 @@
{ {
"nodes": { "nodes": {
"crane": {
"locked": {
"lastModified": 1745022865,
"narHash": "sha256-tXL4qUlyYZEGOHUKUWjmmcvJjjLQ+4U38lPWSc8Cgdo=",
"owner": "ipetkov",
"repo": "crane",
"rev": "25ca4c50039d91ad88cc0b8feacb9ad7f748dedf",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1743550720,
"narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "c621e8422220273271f52058f618c94e405bb0f5",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1744932701, "lastModified": 1716948383,
"narHash": "sha256-fusHbZCyv126cyArUwwKrLdCkgVAIaa/fQJYFlCEqiU=", "narHash": "sha256-SzDKxseEcHR5KzPXLwsemyTR/kaM9whxeiJohbL04rs=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "b024ced1aac25639f8ca8fdfc2f8c4fbd66c48ef", "rev": "ad57eef4ef0659193044870c731987a6df5cf56b",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "id": "nixpkgs",
"ref": "nixos-unstable", "ref": "nixos-unstable",
"repo": "nixpkgs", "type": "indirect"
"type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"systems": "systems", "systems": "systems"
"treefmt-nix": "treefmt-nix"
} }
}, },
"systems": { "systems": {
@ -74,26 +35,6 @@
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1744961264,
"narHash": "sha256-aRmUh0AMwcbdjJHnytg1e5h5ECcaWtIFQa6d9gI85AI=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "8d404a69efe76146368885110f29a2ca3700bee6",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

View file

@ -1,23 +1,39 @@
{ {
description = "An http server that calls nsupdate internally"; description = "An http server that calls nsupdate internally";
inputs = { inputs = {
crane.url = "github:ipetkov/crane"; nixpkgs.url = "nixpkgs/nixos-unstable";
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
systems.url = "github:nix-systems/default"; systems.url = "github:nix-systems/default";
treefmt-nix = {
url = "github:numtide/treefmt-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = outputs = {
inputs: self,
inputs.flake-parts.lib.mkFlake { inherit inputs; } { nixpkgs,
imports = [ ./flake-modules ]; systems,
systems = import inputs.systems; }: let
forEachSupportedSystem = nixpkgs.lib.genAttrs (import systems);
in {
formatter = forEachSupportedSystem (system: nixpkgs.legacyPackages.${system}.alejandra);
packages = forEachSupportedSystem (system: {
default = nixpkgs.legacyPackages.${system}.callPackage ./default.nix {};
});
overlays.default = final: prev: {
webnsupdate = final.callPackage ./default.nix {};
};
nixosModules.default = ./module.nix;
devShells = forEachSupportedSystem (system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.mkShell {
packages = [
pkgs.cargo-insta
pkgs.cargo-udeps
pkgs.mold
];
};
});
}; };
} }

View file

@ -1,2 +0,0 @@
changelog version:
git cliff --unreleased --prepend=CHANGELOG.md --tag='{{ version }}'

View file

@ -1,18 +1,15 @@
{ lib, pkgs, ... }@args:
let
cfg = args.config.services.webnsupdate;
inherit (lib)
mkOption
mkEnableOption
mkPackageOption
types
;
format = pkgs.formats.json { };
in
{ {
lib,
pkgs,
config,
...
}: let
cfg = config.services.webnsupdate;
inherit (lib) mkOption mkEnableOption types;
in {
options.services.webnsupdate = mkOption { options.services.webnsupdate = mkOption {
description = "An HTTP server for nsupdate."; description = "An HTTP server for nsupdate.";
default = { }; default = {};
type = types.submodule { type = types.submodule {
options = { options = {
enable = mkEnableOption "webnsupdate"; enable = mkEnableOption "webnsupdate";
@ -21,39 +18,27 @@ in
Extra arguments to be passed to the webnsupdate server command. Extra arguments to be passed to the webnsupdate server command.
''; '';
type = types.listOf types.str; type = types.listOf types.str;
default = [ ]; default = [];
example = [ "--ip-source" ]; example = ["--ip-source"];
}; };
package = mkPackageOption pkgs "webnsupdate" { }; bindIp = mkOption {
settings = mkOption {
description = "The webnsupdate JSON configuration";
default = { };
type = types.submodule {
freeformType = format.type;
options = {
address = mkOption {
description = '' description = ''
IP address and port to bind to. IP address to bind to.
Setting it to anything other than localhost is very Setting it to anything other than localhost is very insecure as
insecure as `webnsupdate` only supports plain HTTP and `webnsupdate` only supports plain HTTP and should always be behind a
should always be behind a reverse proxy. reverse proxy.
''; '';
type = types.str; type = types.str;
default = "127.0.0.1:5353"; default = "localhost";
example = "[::1]:5353"; example = "0.0.0.0";
}; };
ip_type = mkOption { bindPort = mkOption {
description = ''The allowed IP versions to accept updates from.''; description = "Port to bind to.";
type = types.enum [ type = types.port;
"Both" default = 5353;
"Ipv4Only"
"Ipv6Only"
];
default = "Both";
example = "Ipv4Only";
}; };
password_file = mkOption { passwordFile = mkOption {
description = '' description = ''
The file where the password is stored. The file where the password is stored.
@ -62,7 +47,7 @@ in
type = types.path; type = types.path;
example = "/secrets/webnsupdate.pass"; example = "/secrets/webnsupdate.pass";
}; };
key_file = mkOption { keyFile = mkOption {
description = '' description = ''
The TSIG key that `nsupdate` should use. The TSIG key that `nsupdate` should use.
@ -74,9 +59,9 @@ in
}; };
ttl = mkOption { ttl = mkOption {
description = "The TTL that should be set on the zone records created by `nsupdate`."; description = "The TTL that should be set on the zone records created by `nsupdate`.";
default = "10m"; type = types.ints.positive;
example = "60s"; default = 60;
type = types.str; example = 3600;
}; };
records = mkOption { records = mkOption {
description = '' description = ''
@ -84,16 +69,24 @@ in
Empty lines will be ignored, but whitespace will not be. Empty lines will be ignored, but whitespace will not be.
''; '';
type = types.listOf types.str; type = types.nullOr types.lines;
default = [ ]; default = null;
example = [ example = ''
"example.com." example.com.
"example.org."
"ci.example.org." example.org.
]; ci.example.org.
}; '';
};
}; };
recordsFile = mkOption {
description = ''
The fqdn of records that should be updated.
Empty lines will be ignored, but whitespace will not be.
'';
type = types.nullOr types.path;
default = null;
example = "/secrets/webnsupdate.records";
}; };
user = mkOption { user = mkOption {
description = "The user to run as."; description = "The user to run as.";
@ -109,30 +102,46 @@ in
}; };
}; };
config = config = let
let recordsFile =
configFile = format.generate "webnsupdate.json" cfg.settings; if cfg.recordsFile != null
args = lib.strings.escapeShellArgs ([ "--config=${configFile}" ] ++ cfg.extraArgs); then cfg.recordsFile
cmd = "${lib.getExe cfg.package} ${args}"; else pkgs.writeText "webnsrecords" cfg.records;
args = lib.strings.escapeShellArgs [
"--records"
recordsFile
"--key-file"
cfg.keyFile
"--password-file"
cfg.passwordFile
"--address"
cfg.bindIp
"--port"
(builtins.toString cfg.bindPort)
"--ttl"
(builtins.toString cfg.ttl)
] ++ cfg.extraArgs;
cmd = "${lib.getExe pkgs.webnsupdate} ${args}";
in in
lib.mkIf cfg.enable { lib.mkIf cfg.enable {
# FIXME: re-enable once I stop using the patched version of bind
# warnings = # warnings =
# lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsupported configuration."; # lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsopported configuration.";
assertions = [
{
assertion = (cfg.records != null || cfg.recordsFile != null) && !(cfg.records != null && cfg.recordsFile != null);
message = "Exactly one of `services.webnsupdate.records` and `services.webnsupdate.recordsFile` must be set.";
}
];
systemd.services.webnsupdate = { systemd.services.webnsupdate = {
description = "Web interface for nsupdate."; description = "Web interface for nsupdate.";
wantedBy = [ "multi-user.target" ]; wantedBy = ["multi-user.target"];
after = [ after = ["network.target" "bind.service"];
"network.target" preStart = "${cmd} verify";
"bind.service" path = [pkgs.dig];
];
preStart = "${lib.getExe cfg.package} verify ${configFile}";
path = [ pkgs.dig ];
startLimitIntervalSec = 60; startLimitIntervalSec = 60;
environment.DATA_DIR = "%S/webnsupdate";
serviceConfig = { serviceConfig = {
ExecStart = [ cmd ]; ExecStart = [cmd];
Type = "exec"; Type = "exec";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = "10s"; RestartSec = "10s";
@ -148,9 +157,6 @@ in
# Logs directory and mode # Logs directory and mode
LogsDirectory = "webnsupdate"; LogsDirectory = "webnsupdate";
LogsDirectoryMode = "0750"; LogsDirectoryMode = "0750";
# State directory and mode
StateDirectory = "webnsupdate";
StateDirectoryMode = "0750";
# New file permissions # New file permissions
UMask = "0027"; UMask = "0027";
# Security # Security

View file

@ -1,104 +0,0 @@
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use tower_http::validate_request::ValidateRequestHeaderLayer;
use tracing::{trace, warn};
use crate::password;
pub fn layer<'a, ResBody>(
user_pass_hash: &'a [u8],
salt: &'a str,
) -> ValidateRequestHeaderLayer<Basic<'a, ResBody>> {
ValidateRequestHeaderLayer::custom(Basic::new(user_pass_hash, salt))
}
#[derive(Copy)]
pub struct Basic<'a, ResBody> {
pass: &'a [u8],
salt: &'a str,
_ty: std::marker::PhantomData<fn() -> ResBody>,
}
impl<ResBody> std::fmt::Debug for Basic<'_, ResBody> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BasicAuth")
.field("pass", &self.pass)
.field("salt", &self.salt)
.field("_ty", &self._ty)
.finish()
}
}
impl<ResBody> Clone for Basic<'_, ResBody> {
fn clone(&self) -> Self {
Self {
pass: self.pass,
salt: self.salt,
_ty: std::marker::PhantomData,
}
}
}
impl<'a, ResBody> Basic<'a, ResBody> {
pub fn new(pass: &'a [u8], salt: &'a str) -> Self {
Self {
pass,
salt,
_ty: std::marker::PhantomData,
}
}
fn check_headers(&self, headers: &http::HeaderMap<http::HeaderValue>) -> bool {
let Some(auth) = headers.get(http::header::AUTHORIZATION) else {
return false;
};
// Poor man's split once: https://doc.rust-lang.org/std/primitive.slice.html#method.split_once
let Some(index) = auth.as_bytes().iter().position(|&c| c == b' ') else {
return false;
};
let user_pass = &auth.as_bytes()[index + 1..];
match base64::engine::general_purpose::URL_SAFE.decode(user_pass) {
Ok(user_pass) => {
let hashed = password::hash_basic_auth(&user_pass, self.salt);
if hashed.as_ref() == self.pass {
return true;
}
warn!("rejected update");
trace!(
"mismatched hashes:\nprovided: {}\nstored: {}",
URL_SAFE_NO_PAD.encode(hashed.as_ref()),
URL_SAFE_NO_PAD.encode(self.pass),
);
false
}
Err(err) => {
warn!("received invalid base64 when decoding Basic header: {err}");
false
}
}
}
}
impl<B, ResBody> tower_http::validate_request::ValidateRequest<B> for Basic<'_, ResBody>
where
ResBody: Default,
{
type ResponseBody = ResBody;
fn validate(
&mut self,
request: &mut http::Request<B>,
) -> std::result::Result<(), http::Response<Self::ResponseBody>> {
if self.check_headers(request.headers()) {
return Ok(());
}
let mut res = http::Response::new(ResBody::default());
*res.status_mut() = http::status::StatusCode::UNAUTHORIZED;
res.headers_mut()
.insert(http::header::WWW_AUTHENTICATE, "Basic".parse().unwrap());
Err(res)
}
}

View file

@ -1,253 +0,0 @@
use std::{
fs::File,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
path::PathBuf,
};
use axum_client_ip::ClientIpSource;
use miette::{Context, IntoDiagnostic};
#[derive(Debug, Default, Clone, Copy, serde::Deserialize, serde::Serialize)]
pub enum IpType {
#[default]
Both,
Ipv4Only,
Ipv6Only,
}
impl IpType {
pub fn valid_for_type(self, ip: IpAddr) -> bool {
match self {
IpType::Both => true,
IpType::Ipv4Only => ip.is_ipv4(),
IpType::Ipv6Only => ip.is_ipv6(),
}
}
}
impl std::fmt::Display for IpType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IpType::Both => f.write_str("both"),
IpType::Ipv4Only => f.write_str("ipv4-only"),
IpType::Ipv6Only => f.write_str("ipv6-only"),
}
}
}
impl std::str::FromStr for IpType {
type Err = miette::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"both" => Ok(Self::Both),
"ipv4-only" => Ok(Self::Ipv4Only),
"ipv6-only" => Ok(Self::Ipv6Only),
_ => miette::bail!("expected one of 'ipv4-only', 'ipv6-only' or 'both', got '{s}'"),
}
}
}
/// Webserver settings
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Server {
/// Ip address and port of the server
#[serde(default = "default_address")]
pub address: SocketAddr,
}
/// Password settings
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Password {
/// File containing password to match against
///
/// Should be of the format `username:password` and contain a single password
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_file: Option<PathBuf>,
/// Salt to get more unique hashed passwords and prevent table based attacks
#[serde(default = "default_salt")]
pub salt: Box<str>,
}
/// Records settings
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Records {
/// Time To Live (in seconds) to set on the DNS records
#[serde(
default = "default_ttl",
serialize_with = "humantime_ser",
deserialize_with = "humantime_de"
)]
pub ttl: humantime::Duration,
/// List of domain names for which to update the IP when an update is requested
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[allow(clippy::struct_field_names)]
pub records: Vec<Box<str>>,
/// If provided, when an IPv6 prefix is provided with an update, this will be used to derive
/// the full IPv6 address of the client
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<Ipv6Addr>,
/// If a client id is provided the ipv6 update will be ignored (only the prefix will be used).
/// This domain will point to the ipv6 address instead of the address derived from the client
/// id (usually this is the router).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub router_domain: Option<Box<str>>,
/// Set client IP source
///
/// see: <https://docs.rs/axum-client-ip/latest/axum_client_ip/enum.ClientIpSource.html>
#[serde(default = "default_ip_source")]
pub ip_source: ClientIpSource,
/// Set which IPs to allow updating (ipv4, ipv6 or both)
#[serde(default = "default_ip_type")]
pub ip_type: IpType,
/// Keyfile `nsupdate` should use
///
/// If specified, then `webnsupdate` must have read access to the file
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_file: Option<PathBuf>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Config {
/// Server Configuration
#[serde(flatten)]
pub server: Server,
/// Password Configuration
#[serde(flatten)]
pub password: Password,
/// Records Configuration
#[serde(flatten)]
pub records: Records,
/// The config schema (used for lsp completions)
#[serde(default, rename = "$schema", skip_serializing)]
pub _schema: serde::de::IgnoredAny,
}
impl Config {
/// Load the configuration without verifying it
pub fn load(path: &std::path::Path) -> miette::Result<Self> {
serde_json::from_reader::<File, Self>(
File::open(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed open {}", path.display()))?,
)
.into_diagnostic()
.wrap_err_with(|| format!("failed to load configuration from {}", path.display()))
}
/// Ensure only a verified configuration is returned
pub fn verified(self) -> miette::Result<Self> {
self.verify()?;
Ok(self)
}
/// Verify the configuration
pub fn verify(&self) -> Result<(), Invalid> {
let mut invalid_records: Vec<miette::Error> = self
.records
.records
.iter()
.filter_map(|record| crate::records::validate_record_str(record).err())
.collect();
invalid_records.extend(
self.records
.router_domain
.as_ref()
.and_then(|domain| crate::records::validate_record_str(domain).err()),
);
let err = Invalid { invalid_records };
if err.invalid_records.is_empty() {
Ok(())
} else {
Err(err)
}
}
}
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
#[error("the configuration was invalid")]
pub struct Invalid {
#[related]
pub invalid_records: Vec<miette::Error>,
}
// --- Default Values (sadly serde doesn't have a way to specify a constant as a default value) ---
fn default_ttl() -> humantime::Duration {
super::DEFAULT_TTL.into()
}
fn default_salt() -> Box<str> {
super::DEFAULT_SALT.into()
}
fn default_address() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 5353)
}
fn default_ip_source() -> ClientIpSource {
ClientIpSource::RightmostXForwardedFor
}
fn default_ip_type() -> IpType {
IpType::Both
}
fn humantime_de<'de, D>(de: D) -> Result<humantime::Duration, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl serde::de::Visitor<'_> for Visitor {
type Value = humantime::Duration;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "a duration (e.g. 5s)")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
v.parse().map_err(E::custom)
}
}
de.deserialize_str(Visitor)
}
fn humantime_ser<S>(duration: &humantime::Duration, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
ser.serialize_str(&duration.to_string())
}
#[test]
fn default_values_config_snapshot() {
let config: Config = serde_json::from_str("{}").unwrap();
insta::assert_json_snapshot!(config, @r#"
{
"address": "127.0.0.1:5353",
"salt": "UpdateMyDNS",
"ttl": {
"secs": 60,
"nanos": 0
},
"ip_source": "RightmostXForwardedFor",
"ip_type": "Both"
}
"#);
}

File diff suppressed because it is too large Load diff

View file

@ -1,156 +0,0 @@
use std::{
ffi::OsStr,
net::IpAddr,
path::Path,
process::{ExitStatus, Stdio},
time::Duration,
};
use tokio::io::AsyncWriteExt;
use tracing::{debug, warn};
pub enum Action<'a> {
// Reassign a domain to a different IP
Reassign {
domain: &'a str,
to: IpAddr,
ttl: Duration,
},
}
impl<'a> Action<'a> {
/// Create a set of [`Action`]s reassigning the domains in `records` to the specified
/// [`IpAddr`]
pub fn from_records(
to: IpAddr,
ttl: Duration,
records: &'a [&'a str],
) -> impl IntoIterator<Item = Self> + std::iter::ExactSizeIterator + 'a {
records
.iter()
.map(move |&domain| Action::Reassign { domain, to, ttl })
}
}
impl std::fmt::Display for Action<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Action::Reassign { domain, to, ttl } => {
let ttl = ttl.as_secs();
let kind = match to {
IpAddr::V4(_) => "A",
IpAddr::V6(_) => "AAAA",
};
// Delete previous record of type `kind`
writeln!(f, "update delete {domain} {ttl} IN {kind}")?;
// Add record with new IP
writeln!(f, "update add {domain} {ttl} IN {kind} {to}")
}
}
}
}
#[tracing::instrument(level = "trace", skip(actions), ret(level = "warn"))]
pub async fn nsupdate(
key_file: Option<&Path>,
actions: impl IntoIterator<Item = Action<'_>>,
) -> std::io::Result<ExitStatus> {
let mut cmd = tokio::process::Command::new("nsupdate");
if let Some(key_file) = key_file {
cmd.args([OsStr::new("-k"), key_file.as_os_str()]);
}
debug!("spawning new process");
let mut child = cmd
.stdin(Stdio::piped())
.spawn()
.inspect_err(|err| warn!("failed to spawn child: {err}"))?;
let mut stdin = child.stdin.take().expect("stdin not present");
debug!("sending update request");
let mut buf = Vec::new();
update_ns_records(&mut buf, actions).unwrap();
stdin
.write_all(&buf)
.await
.inspect_err(|err| warn!("failed to write to the stdin of nsupdate: {err}"))?;
debug!("closing stdin");
stdin
.shutdown()
.await
.inspect_err(|err| warn!("failed to close stdin to nsupdate: {err}"))?;
debug!("waiting for nsupdate to exit");
child
.wait()
.await
.inspect_err(|err| warn!("failed to wait for child: {err}"))
}
fn update_ns_records<'a>(
mut buf: impl std::io::Write,
actions: impl IntoIterator<Item = Action<'a>>,
) -> std::io::Result<()> {
writeln!(buf, "server 127.0.0.1")?;
for action in actions {
write!(buf, "{action}")?;
}
writeln!(buf, "send")?;
writeln!(buf, "quit")
}
#[cfg(test)]
mod test {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use insta::assert_snapshot;
use super::{update_ns_records, Action};
use crate::DEFAULT_TTL;
#[test]
#[allow(non_snake_case)]
fn expected_update_string_A() {
let mut buf = Vec::new();
let actions = Action::from_records(
IpAddr::V4(Ipv4Addr::LOCALHOST),
DEFAULT_TTL,
&["example.com.", "example.org.", "example.net."],
);
update_ns_records(&mut buf, actions).unwrap();
assert_snapshot!(String::from_utf8(buf).unwrap(), @r###"
server 127.0.0.1
update delete example.com. 60 IN A
update add example.com. 60 IN A 127.0.0.1
update delete example.org. 60 IN A
update add example.org. 60 IN A 127.0.0.1
update delete example.net. 60 IN A
update add example.net. 60 IN A 127.0.0.1
send
quit
"###);
}
#[test]
#[allow(non_snake_case)]
fn expected_update_string_AAAA() {
let mut buf = Vec::new();
let actions = Action::from_records(
IpAddr::V6(Ipv6Addr::LOCALHOST),
DEFAULT_TTL,
&["example.com.", "example.org.", "example.net."],
);
update_ns_records(&mut buf, actions).unwrap();
assert_snapshot!(String::from_utf8(buf).unwrap(), @r###"
server 127.0.0.1
update delete example.com. 60 IN AAAA
update add example.com. 60 IN AAAA ::1
update delete example.org. 60 IN AAAA
update add example.org. 60 IN AAAA ::1
update delete example.net. 60 IN AAAA
update add example.net. 60 IN AAAA ::1
send
quit
"###);
}
}

View file

@ -1,80 +0,0 @@
//! Make a password for use with webnsupdate
//!
//! You should call this command an give it's output to the app/script that will update the DNS
//! records
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use base64::prelude::*;
use miette::{Context, IntoDiagnostic, Result};
use ring::digest::Digest;
/// Create a password file
///
/// If `--password-file` is provided, the password is written to that file
#[derive(Debug, clap::Args)]
pub struct Mkpasswd {
/// The username
username: String,
/// The password
password: String,
/// An application specific value
#[arg(long, default_value = crate::DEFAULT_SALT)]
salt: String,
/// The file to write the password to
password_file: Option<PathBuf>,
}
impl Mkpasswd {
pub fn process(self, _args: &crate::Opts) -> Result<()> {
mkpasswd(self)
}
}
pub fn hash_basic_auth(user_pass: &[u8], salt: &str) -> Digest {
let mut context = ring::digest::Context::new(&ring::digest::SHA256);
context.update(user_pass);
context.update(salt.as_bytes());
context.finish()
}
pub fn hash_identity(username: &str, password: &str, salt: &str) -> Digest {
let mut context = ring::digest::Context::new(&ring::digest::SHA256);
context.update(username.as_bytes());
context.update(b":");
context.update(password.as_bytes());
context.update(salt.as_bytes());
context.finish()
}
pub fn mkpasswd(
Mkpasswd {
username,
password,
salt,
password_file,
}: Mkpasswd,
) -> miette::Result<()> {
let hash = hash_identity(&username, &password, &salt);
let encoded = BASE64_URL_SAFE_NO_PAD.encode(hash.as_ref());
let Some(path) = password_file.as_deref() else {
println!("{encoded}");
return Ok(());
};
let err = || format!("trying to save password hash to {}", path.display());
std::fs::File::options()
.mode(0o600)
.create_new(true)
.open(path)
.into_diagnostic()
.wrap_err_with(err)?
.write_all(encoded.as_bytes())
.into_diagnostic()
.wrap_err_with(err)?;
Ok(())
}

View file

@ -1,187 +0,0 @@
//! Deal with the DNS records
use miette::{ensure, miette, LabeledSpan, Result};
pub fn validate_record_str(record: &str) -> Result<()> {
validate_line(0, record).map_err(|err| err.with_source_code(String::from(record)))
}
fn validate_line(offset: usize, line: &str) -> Result<()> {
if line.is_empty() {
return Ok(());
}
ensure!(
line.len() <= 255,
miette!(
labels = [LabeledSpan::new(
Some("this line".to_string()),
offset,
line.len(),
)],
help = "fully qualified domain names can be at most 255 characters long",
url = "https://en.wikipedia.org/wiki/Fully_qualified_domain_name",
"hostname too long ({} octets)",
line.len(),
)
);
ensure!(
line.ends_with('.'),
miette!(
labels = [LabeledSpan::new(
Some("last character".to_string()),
offset + line.len() - 1,
1,
)],
help = "hostname should be a fully qualified domain name (end with a '.')",
url = "https://en.wikipedia.org/wiki/Fully_qualified_domain_name",
"not a fully qualified domain name"
)
);
let mut label_offset = 0usize;
for label in line.strip_suffix('.').unwrap_or(line).split('.') {
validate_label(offset + label_offset, label)?;
label_offset += label.len() + 1;
}
Ok(())
}
fn validate_label(offset: usize, label: &str) -> Result<()> {
ensure!(
!label.is_empty(),
miette!(
labels = [LabeledSpan::new(
Some("label".to_string()),
offset,
label.len(),
)],
help = "each label should have at least one character",
url = "https://en.wikipedia.org/wiki/Fully_qualified_domain_name",
"empty label",
)
);
ensure!(
label.len() <= 63,
miette!(
labels = [LabeledSpan::new(
Some("label".to_string()),
offset,
label.len(),
)],
help = "labels should be at most 63 octets",
url = "https://en.wikipedia.org/wiki/Fully_qualified_domain_name",
"label too long ({} octets)",
label.len(),
)
);
for (octet_offset, octet) in label.bytes().enumerate() {
validate_octet(offset + octet_offset, octet)?;
}
Ok(())
}
fn validate_octet(offset: usize, octet: u8) -> Result<()> {
let spans = || [LabeledSpan::new(Some("octet".to_string()), offset, 1)];
ensure!(
octet.is_ascii(),
miette!(
labels = spans(),
help = "we only accept ascii characters",
url = "https://en.wikipedia.org/wiki/Hostname#Syntax",
"invalid octet: '{}'",
octet.escape_ascii(),
)
);
ensure!(
octet.is_ascii_alphanumeric() || octet == b'-' || octet == b'_',
miette!(
labels = spans(),
help = "hostnames are only allowed to contain characters in [a-zA-Z0-9_-]",
url = "https://en.wikipedia.org/wiki/Hostname#Syntax",
"invalid octet: '{}'",
octet.escape_ascii(),
)
);
Ok(())
}
#[cfg(test)]
mod test {
use crate::records::validate_record_str;
macro_rules! assert_miette_snapshot {
($diag:expr) => {{
use std::borrow::Borrow;
use insta::{with_settings, assert_snapshot};
use miette::{GraphicalReportHandler, GraphicalTheme};
let mut out = String::new();
GraphicalReportHandler::new_themed(GraphicalTheme::unicode_nocolor())
.with_width(80)
.render_report(&mut out, $diag.borrow())
.unwrap();
with_settings!({
description => stringify!($diag)
}, {
assert_snapshot!(out);
});
}};
}
#[test]
fn valid_records() -> miette::Result<()> {
for record in [
"example.com.",
"example.org.",
"example.net.",
"subdomain.example.com.",
] {
validate_record_str(record)?;
}
Ok(())
}
#[test]
fn hostname_too_long() {
let err = validate_record_str("example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.").unwrap_err();
assert_miette_snapshot!(err);
}
#[test]
fn not_fqd() {
let err = validate_record_str("example.net").unwrap_err();
assert_miette_snapshot!(err);
}
#[test]
fn empty_label() {
let err = validate_record_str("name..example.org.").unwrap_err();
assert_miette_snapshot!(err);
}
#[test]
fn label_too_long() {
let err = validate_record_str("name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.").unwrap_err();
assert_miette_snapshot!(err);
}
#[test]
fn invalid_ascii() {
let err = validate_record_str("name.this-is-not-ascii-ß.example.org.").unwrap_err();
assert_miette_snapshot!(err);
}
#[test]
fn invalid_octet() {
let err =
validate_record_str("name.this-character:-is-not-allowed.example.org.").unwrap_err();
assert_miette_snapshot!(err);
}
}

View file

@ -1,14 +0,0 @@
---
source: src/records.rs
description: err
expression: out
---
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
× empty label
╭────
1 │ name..example.org.
· ▲
· ╰── label
╰────
help: each label should have at least one character

View file

@ -1,14 +0,0 @@
---
source: src/records.rs
description: err
expression: out
---
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
× hostname too long (260 octets)
╭────
1 │ example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.
· ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
· ╰── this line
╰────
help: fully qualified domain names can be at most 255 characters long

View file

@ -1,14 +0,0 @@
---
source: src/records.rs
description: err
expression: out
---
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
× invalid octet: '\xc3'
╭────
1 │ name.this-is-not-ascii-ß.example.org.
· ┬
· ╰── octet
╰────
help: we only accept ascii characters

View file

@ -1,14 +0,0 @@
---
source: src/records.rs
description: err
expression: out
---
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
× invalid octet: ':'
╭────
1 │ name.this-character:-is-not-allowed.example.org.
· ┬
· ╰── octet
╰────
help: hostnames are only allowed to contain characters in [a-zA-Z0-9_-]

View file

@ -1,14 +0,0 @@
---
source: src/records.rs
description: err
expression: out
---
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
× label too long (78 octets)
╭────
1 │ name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.
· ───────────────────────────────────────┬──────────────────────────────────────
· ╰── label
╰────
help: labels should be at most 63 octets

View file

@ -1,14 +0,0 @@
---
source: src/records.rs
description: err
expression: out
---
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
× not a fully qualified domain name
╭────
1 │ example.net
· ┬
· ╰── last character
╰────
help: hostname should be a fully qualified domain name (end with a '.')