Compare commits

..

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

31 changed files with 1222 additions and 2393 deletions

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,33 +1,14 @@
on: [push]
jobs:
check:
runs-on: nixos
steps:
- uses: https://git.salame.cl/actions/checkout@v4
- run: nix --version
- run: nix flake check --keep-going --verbose --print-build-logs
build:
runs-on: nixos
steps:
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: https://git.salame.cl/actions/checkout@v4
- run: nix --version
- name: Build Package
run: |
nix build --print-build-logs .#
check-integration-tests:
needs: build # we use the built binaries in the checks
runs-on: nixos
steps:
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: nix --version
- name: Run tests
run: |
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@838f2050208b41c339803a1111608d7182bbda3e" # main
with:
comment-on-pr: ${{ github.ref_name != 'main' }}
generate-artifact: ${{ github.ref_name == 'main' }}
do-comparison: true
job-name: report-size
- run: nix build --print-build-logs .#

View file

@ -1,14 +0,0 @@
on:
push:
paths:
# only run if the renovate config changed
- renovate.json
jobs:
check-renovaterc:
runs-on: nixos
steps:
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: nix --version
- name: Validate renovaterc
run: |
nix shell nixpkgs#renovate --command renovate-config-validator

View file

@ -0,0 +1,62 @@
on:
workflow_dispatch:
schedule:
# 03:42 on Saturdays
- cron: '42 3 * * 6'
env:
PR_TITLE: Weekly `cargo update` of dependencies
PR_MESSAGE: |
Automation to keep dependencies in `Cargo.lock` current.
The following is the output from `cargo update`:
COMMIT_MESSAGE: "chore: cargo update \n\n"
jobs:
update-cargo:
runs-on: nixos
env:
BRANCH_NAME: cargo-update
steps:
- uses: https://git.salame.cl/actions/checkout@v4
- run: nix --version
- run: nix run .#cargo-update
- name: craft PR body and commit message
run: |
set -xeuo pipefail
echo "${COMMIT_MESSAGE}" > commit.txt
cat cargo_update.log >> commit.txt
echo "${PR_MESSAGE}" > body.md
echo '```txt' >> body.md
cat cargo_update.log >> body.md
echo '```' >> body.md
- name: commit
run: |
set -xeuo pipefail
git config user.name forgejo-actions
git config user.email forgejo-actions@salame.cl
git switch --force-create "$BRANCH_NAME"
git add ./Cargo.lock
DIFF="$(git diff --staged)"
if [[ "$DIFF" == "" ]]; then
echo "Cargo.lock was not changed, bailing out and not making a PR"
exit 1
fi
git commit --no-verify --file=commit.txt
- name: push
run: |
set -xeuo pipefail
git push --no-verify --force --set-upstream origin "$BRANCH_NAME"
- name: open new pull request
env:
# We have to use a Personal Access Token (PAT) here.
# PRs opened from a workflow using the standard `GITHUB_TOKEN` in GitHub Actions
# do not automatically trigger more workflows:
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow
# GITHUB_TOKEN: ${{ secrets.DEPS_UPDATER_GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -xeuo pipefail
tea login add --token "$GITHUB_TOKEN"
tea pr create --title "${PR_TITLE}" --description "$(cat body.md)" --repo "$GITHUB_REPOSITORY"

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 -->

604
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,48 +1,35 @@
cargo-features = ["codegen-backend"]
[package]
description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate"
name = "webnsupdate"
version = "0.3.6"
edition = "2024"
license = "MIT"
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 }
version = "0.3.0"
edition = "2021"
[dependencies]
axum = "0.8"
axum-client-ip = "1.0"
axum = "0.7"
axum-auth = { version = "0.7", default-features = false, features = [
"auth-basic",
] }
axum-client-ip = "0.6"
base64 = "0.22"
clap = { version = "4", features = ["derive", "env"] }
clap-verbosity-flag = { version = "3", default-features = false, features = [
"tracing",
] }
clap-verbosity-flag = "2"
http = "1"
humantime = "2.2.0"
miette = { version = "7", features = ["fancy"] }
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"] }
tokio = { version = "1", features = [
"macros",
"rt",
"process",
"io-util",
] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
insta = { version = "=1.43.1", features = ["json"] }
[profile.release]
opt-level = "s"
panic = "abort"
lto = true
strip = true
codegen-units = 1
insta = "1"
[profile.dev]
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 +0,0 @@
{
pkgs ?
(builtins.getFlake (builtins.toString ./.)).inputs.nixpkgs.legacyPackages.${builtins.currentSystem},
lib ? pkgs.lib,
crane ? (builtins.getFlake (builtins.toString ./.)).inputs.crane,
pkgSrc ? ./.,
mold ? pkgs.mold,
}:
let
craneLib = crane.mkLib pkgs;
src = craneLib.cleanCargoSource pkgSrc;
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 = [ mold ];
meta = {
license = lib.licenses.mit;
homepage = "https://github.com/jalil-salame/webnsupdate";
mainProgram = "webnsupdate";
};
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
in
craneLib.buildPackage (
lib.mergeAttrsList [
commonArgs
{ inherit cargoArtifacts; }
]
)

View file

@ -1,20 +1,13 @@
{ lib, inputs, ... }:
let
webnsupdate = ../module.nix;
cargoToml = lib.importTOML ../Cargo.toml;
in
{ inputs, ... }:
{
imports = [
inputs.treefmt-nix.flakeModule
./package.nix
./overlay.nix
./module.nix
./tests.nix
];
flake.nixosModules = {
default = webnsupdate;
inherit webnsupdate;
};
perSystem =
{ pkgs, ... }:
{
@ -23,21 +16,17 @@ in
projectRootFile = "flake.nix";
programs = {
nixfmt.enable = true;
rustfmt = {
enable = true;
inherit (cargoToml.package) edition; # respect the package's edition
};
rustfmt.enable = true;
statix.enable = true;
typos.enable = true;
};
};
devShells.default = pkgs.mkShellNoCC {
packages = with pkgs; [
cargo-insta
cargo-udeps
mold
git-cliff
packages = [
pkgs.cargo-insta
pkgs.cargo-udeps
pkgs.mold
];
};
};

196
flake-modules/module.nix Normal file
View file

@ -0,0 +1,196 @@
let
module =
{
lib,
pkgs,
config,
...
}:
let
cfg = config.services.webnsupdate;
inherit (lib)
mkOption
mkEnableOption
mkPackageOption
types
;
in
{
options.services.webnsupdate = mkOption {
description = "An HTTP server for nsupdate.";
default = { };
type = types.submodule {
options = {
enable = mkEnableOption "webnsupdate";
extraArgs = mkOption {
description = ''
Extra arguments to be passed to the webnsupdate server command.
'';
type = types.listOf types.str;
default = [ ];
example = [ "--ip-source" ];
};
package = mkPackageOption pkgs "webnsupdate" { };
bindIp = mkOption {
description = ''
IP address to bind to.
Setting it to anything other than localhost is very insecure as
`webnsupdate` only supports plain HTTP and should always be behind a
reverse proxy.
'';
type = types.str;
default = "localhost";
example = "0.0.0.0";
};
bindPort = mkOption {
description = "Port to bind to.";
type = types.port;
default = 5353;
};
passwordFile = mkOption {
description = ''
The file where the password is stored.
This file can be created by running `webnsupdate mkpasswd $USERNAME $PASSWORD`.
'';
type = types.path;
example = "/secrets/webnsupdate.pass";
};
keyFile = mkOption {
description = ''
The TSIG key that `nsupdate` should use.
This file will be passed to `nsupdate` through the `-k` option, so look
at `man 8 nsupdate` for information on the key's format.
'';
type = types.path;
example = "/secrets/webnsupdate.key";
};
ttl = mkOption {
description = "The TTL that should be set on the zone records created by `nsupdate`.";
type = types.ints.positive;
default = 60;
example = 3600;
};
records = mkOption {
description = ''
The fqdn of records that should be updated.
Empty lines will be ignored, but whitespace will not be.
'';
type = types.nullOr types.lines;
default = null;
example = ''
example.com.
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 {
description = "The user to run as.";
type = types.str;
default = "named";
};
group = mkOption {
description = "The group to run as.";
type = types.str;
default = "named";
};
};
};
};
config =
let
recordsFile =
if cfg.recordsFile != null then cfg.recordsFile 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)
"--data-dir=%S/webnsupdate"
]
++ cfg.extraArgs
);
cmd = "${lib.getExe cfg.package} ${args}";
in
lib.mkIf cfg.enable {
# warnings =
# 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 = {
description = "Web interface for nsupdate.";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"bind.service"
];
preStart = "${cmd} verify";
path = [ pkgs.dig ];
startLimitIntervalSec = 60;
serviceConfig = {
ExecStart = [ cmd ];
Type = "exec";
Restart = "on-failure";
RestartSec = "10s";
# User and group
User = cfg.user;
Group = cfg.group;
# Runtime directory and mode
RuntimeDirectory = "webnsupdate";
RuntimeDirectoryMode = "0750";
# Cache directory and mode
CacheDirectory = "webnsupdate";
CacheDirectoryMode = "0750";
# Logs directory and mode
LogsDirectory = "webnsupdate";
LogsDirectoryMode = "0750";
# State directory and mode
StateDirectory = "webnsupdate";
StateDirectoryMode = "0750";
# New file permissions
UMask = "0027";
# Security
NoNewPrivileges = true;
ProtectHome = true;
};
};
};
};
in
{
flake.nixosModules = {
default = module;
webnsupdate = module;
};
}

View file

@ -0,0 +1,5 @@
{
flake = {
overlays.default = _final: prev: { webnsupdate = prev.callPackage ../default.nix { }; };
};
}

View file

@ -1,24 +1,9 @@
{ inputs, ... }:
let
inherit (inputs) crane;
in
{ inputs, lib, ... }:
{
flake.overlays.default = final: prev: {
webnsupdate = prev.callPackage ../default.nix {
inherit crane;
pkgSrc = inputs.self;
};
};
perSystem =
{
system,
pkgs,
lib,
...
}:
{ pkgs, ... }:
let
craneLib = (crane.mkLib pkgs).overrideToolchain (pkgs: pkgs.rust-bin.stable.latest.default);
craneLib = inputs.crane.mkLib pkgs;
src = craneLib.cleanCargoSource inputs.self;
commonArgs = {
@ -39,36 +24,46 @@ in
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
withArtifacts = lib.mergeAttrsList [
commonArgs
{ inherit cargoArtifacts; }
];
webnsupdate = pkgs.callPackage ../default.nix {
inherit crane;
pkgSrc = src;
};
webnsupdate = craneLib.buildPackage (
lib.mergeAttrsList [
commonArgs
{ inherit cargoArtifacts; }
]
);
in
{
# Consume the rust-rust-overlay
_module.args.pkgs = import inputs.nixpkgs {
inherit system;
overlays = [ inputs.rust-overlay.overlays.default ];
};
checks = {
nextest = craneLib.cargoNextest withArtifacts;
clippy = craneLib.cargoClippy (
lib.mergeAttrsList [
withArtifacts
{ cargoClippyExtraArgs = "--all-targets -- --deny warnings"; }
commonArgs
{
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
}
]
);
nextest = craneLib.cargoNextest (
lib.mergeAttrsList [
commonArgs
{ inherit cargoArtifacts; }
]
);
};
packages = {
inherit webnsupdate;
inherit (pkgs) git-cliff;
default = webnsupdate;
cargo-update = pkgs.writeShellApplication {
name = "cargo-update-lockfile";
runtimeInputs = with pkgs; [
cargo
gnused
];
text = ''
CARGO_TERM_COLOR=never cargo update 2>&1 | sed '/crates.io index/d' | tee -a cargo_update.log
'';
};
};
};
}

View file

@ -6,337 +6,138 @@
checks =
let
testDomain = "webnstest.example";
lastIPPath = "/var/lib/webnsupdate/last-ip.json";
dynamicZonesDir = "/var/lib/named/zones";
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
$ORIGIN .
$TTL 60 ; 1 minute
${testDomain} IN SOA ns1.${testDomain}. admin.${testDomain}. (
1 ; serial
21600 ; refresh (6 hours)
3600 ; retry (1 hour)
604800 ; expire (1 week)
86400) ; negative caching TTL (1 day)
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
IN NS ns1.${testDomain}.
$ORIGIN ${testDomain}.
${testDomain}. IN A 127.0.0.1
${testDomain}. IN AAAA ::1
ns1 IN A 127.0.0.1
ns1 IN AAAA ::1
nsupdate IN A 127.0.0.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; };
'';
webnsupdate-machine = {
imports = [ self.nixosModules.webnsupdate ];
config = {
environment.systemPackages = [
pkgs.dig
pkgs.curl
];
services = {
webnsupdate = {
enable = true;
bindIp = "127.0.0.1";
keyFile = "/etc/bind/rndc.key";
# test:test (user:password)
passwordFile = pkgs.writeText "webnsupdate.pass" "FQoNmuU1BKfg8qsU96F6bK5ykp2b0SLe3ZpB3nbtfZA";
package = self'.packages.webnsupdate;
extraArgs = [
"-vvv" # debug messages
"--ip-source=ConnectInfo"
];
records = ''
test1.${testDomain}.
test2.${testDomain}.
test3.${testDomain}.
'';
};
bind = {
enable = true;
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}
${pkgs.coreutils}/bin/mkdir -m 0755 -p ${dynamicZonesDir}
chown "named" ${dynamicZonesDir}
chown "named" /var/lib/named
# 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;
};
module-test = pkgs.testers.runNixOSTest {
name = "webnsupdate-module";
nodes.machine = webnsupdate-machine;
testScript = ''
machine.start(allow_reboot=True)
machine.wait_for_unit("webnsupdate.service")
# ensure base DNS records area available
with subtest("query base DNS records"):
machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}")
machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}")
machine.succeed("dig @127.0.0.1 nsupdate.${testDomain} | grep ^nsupdate.${testDomain}")
# ensure webnsupdate managed records are missing
with subtest("query webnsupdate DNS records (fail)"):
machine.fail("dig @127.0.0.1 test1.${testDomain} | grep ^test1.${testDomain}")
machine.fail("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}")
machine.fail("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}")
with subtest("update webnsupdate DNS records (invalid auth)"):
machine.fail("curl --fail --silent -u test1:test1 -X GET http://localhost:5353/update")
machine.fail("cat /var/lib/webnsupdate/last-ip") # no last-ip set yet
# ensure webnsupdate managed records are missing
with subtest("query webnsupdate DNS records (fail)"):
machine.fail("dig @127.0.0.1 test1.${testDomain} | grep ^test1.${testDomain}")
machine.fail("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}")
machine.fail("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}")
with subtest("update webnsupdate DNS records (valid auth)"):
machine.succeed("curl --fail --silent -u test:test -X GET http://localhost:5353/update")
machine.succeed("cat /var/lib/webnsupdate/last-ip")
# ensure webnsupdate managed records are available
with subtest("query webnsupdate DNS records (succeed)"):
machine.succeed("dig @127.0.0.1 test1.${testDomain} | grep ^test1.${testDomain}")
machine.succeed("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}")
machine.succeed("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}")
machine.reboot()
machine.succeed("cat /var/lib/webnsupdate/last-ip")
machine.wait_for_unit("webnsupdate.service")
machine.succeed("cat /var/lib/webnsupdate/last-ip")
# ensure base DNS records area available after a reboot
with subtest("query base DNS records"):
machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}")
machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}")
machine.succeed("dig @127.0.0.1 nsupdate.${testDomain} | grep ^nsupdate.${testDomain}")
# ensure webnsupdate managed records are available after a reboot
with subtest("query webnsupdate DNS records (succeed)"):
machine.succeed("dig @127.0.0.1 test1.${testDomain} | grep ^test1.${testDomain}")
machine.succeed("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}")
machine.succeed("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}")
'';
};
};
};

55
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1751562746,
"narHash": "sha256-smpugNIkmDeicNz301Ll1bD7nFOty97T79m4GUMUczA=",
"lastModified": 1730060262,
"narHash": "sha256-RMgSVkZ9H03sxC+Vh4jxtLTCzSjPq18UWpiM0gq6shQ=",
"owner": "ipetkov",
"repo": "crane",
"rev": "aed2020fd3dc26e1e857d4107a5a67a33ab6c1fd",
"rev": "498d9f122c413ee1154e8131ace5a35a80d8fa76",
"type": "github"
},
"original": {
@ -22,11 +22,11 @@
]
},
"locked": {
"lastModified": 1751413152,
"narHash": "sha256-Tyw1RjYEsp5scoigs1384gIg6e0GoBVjms4aXFfRssQ=",
"lastModified": 1727826117,
"narHash": "sha256-K5ZLCyfO/Zj9mPFldf3iwS6oZStJcU4tSpiXTMYaaL0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "77826244401ea9de6e3bac47c2db46005e1f30b5",
"rev": "3d04084d54bedc3d6b8b736c70ef449225c361b1",
"type": "github"
},
"original": {
@ -37,15 +37,17 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1751332518,
"narHash": "sha256-BTVn9nIjhCeWpaxOs3Wbhwum4xID4p36dbmEfLDyh9Y=",
"rev": "3016b4b15d13f3089db8a41ef937b13a9e33a8df",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre823481.3016b4b15d13/nixexprs.tar.xz?rev=3016b4b15d13f3089db8a41ef937b13a9e33a8df"
"lastModified": 1728492678,
"narHash": "sha256-9UTxR8eukdg+XZeHgxW5hQA9fIKHsKCdOIUycTryeVw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5633bcff0c6162b9e4b5f1264264611e950c8ec7",
"type": "github"
},
"original": {
"type": "tarball",
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
"id": "nixpkgs",
"ref": "nixos-unstable",
"type": "indirect"
}
},
"root": {
@ -53,31 +55,10 @@
"crane": "crane",
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay",
"systems": "systems",
"treefmt-nix": "treefmt-nix"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1751510438,
"narHash": "sha256-m8PjOoyyCR4nhqtHEBP1tB/jF+gJYYguSZmUmVTEAQE=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "7f415261f298656f8164bd636c0dc05af4e95b6b",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
@ -100,11 +81,11 @@
]
},
"locked": {
"lastModified": 1750931469,
"narHash": "sha256-0IEdQB1nS+uViQw4k3VGUXntjkDp7aAlqcxdewb/hAc=",
"lastModified": 1729613947,
"narHash": "sha256-XGOvuIPW1XRfPgHtGYXd5MAmJzZtOuwlfKDgxX5KT3s=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "ac8e6f32e11e9c7f153823abc3ab007f2a65d3e1",
"rev": "aac86347fb5063960eccb19493e0cadcdb4205ca",
"type": "github"
},
"original": {

View file

@ -1,22 +1,17 @@
{
description = "An http server that calls nsupdate internally";
inputs = {
nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz";
crane.url = "github:ipetkov/crane";
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
nixpkgs.url = "nixpkgs/nixos-unstable";
systems.url = "github:nix-systems/default";
treefmt-nix = {
url = "github:numtide/treefmt-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
systems.url = "github:nix-systems/default";
};
outputs =

View file

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

View file

@ -1,162 +0,0 @@
{ lib, pkgs, ... }@args:
let
cfg = args.config.services.webnsupdate;
inherit (lib)
mkOption
mkEnableOption
mkPackageOption
types
;
format = pkgs.formats.json { };
in
{
options.services.webnsupdate = mkOption {
description = "An HTTP server for nsupdate.";
default = { };
type = types.submodule {
options = {
enable = mkEnableOption "webnsupdate";
extraArgs = mkOption {
description = ''
Extra arguments to be passed to the webnsupdate server command.
'';
type = types.listOf types.str;
default = [ ];
example = [ "--ip-source" ];
};
package = mkPackageOption pkgs "webnsupdate" { };
settings = mkOption {
description = "The webnsupdate JSON configuration";
default = { };
type = types.submodule {
freeformType = format.type;
options = {
address = mkOption {
description = ''
IP address and port to bind to.
Setting it to anything other than localhost is very
insecure as `webnsupdate` only supports plain HTTP and
should always be behind a reverse proxy.
'';
type = types.str;
default = "127.0.0.1:5353";
example = "[::1]:5353";
};
ip_type = mkOption {
description = ''The allowed IP versions to accept updates from.'';
type = types.enum [
"Both"
"Ipv4Only"
"Ipv6Only"
];
default = "Both";
example = "Ipv4Only";
};
password_file = mkOption {
description = ''
The file where the password is stored.
This file can be created by running `webnsupdate mkpasswd $USERNAME $PASSWORD`.
'';
type = types.path;
example = "/secrets/webnsupdate.pass";
};
key_file = mkOption {
description = ''
The TSIG key that `nsupdate` should use.
This file will be passed to `nsupdate` through the `-k` option, so look
at `man 8 nsupdate` for information on the key's format.
'';
type = types.path;
example = "/secrets/webnsupdate.key";
};
ttl = mkOption {
description = "The TTL that should be set on the zone records created by `nsupdate`.";
default = "10m";
example = "60s";
type = types.str;
};
records = mkOption {
description = ''
The fqdn of records that should be updated.
Empty lines will be ignored, but whitespace will not be.
'';
type = types.listOf types.str;
default = [ ];
example = [
"example.com."
"example.org."
"ci.example.org."
];
};
};
};
};
user = mkOption {
description = "The user to run as.";
type = types.str;
default = "named";
};
group = mkOption {
description = "The group to run as.";
type = types.str;
default = "named";
};
};
};
};
config =
let
configFile = format.generate "webnsupdate.json" cfg.settings;
args = lib.strings.escapeShellArgs ([ "--config=${configFile}" ] ++ cfg.extraArgs);
cmd = "${lib.getExe cfg.package} ${args}";
in
lib.mkIf cfg.enable {
# FIXME: re-enable once I stop using the patched version of bind
# warnings =
# lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsupported configuration.";
systemd.services.webnsupdate = {
description = "Web interface for nsupdate.";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"bind.service"
];
preStart = "${lib.getExe cfg.package} verify ${configFile}";
path = [ pkgs.dig ];
startLimitIntervalSec = 60;
environment.DATA_DIR = "%S/webnsupdate";
serviceConfig = {
ExecStart = [ cmd ];
Type = "exec";
Restart = "on-failure";
RestartSec = "10s";
# User and group
User = cfg.user;
Group = cfg.group;
# Runtime directory and mode
RuntimeDirectory = "webnsupdate";
RuntimeDirectoryMode = "0750";
# Cache directory and mode
CacheDirectory = "webnsupdate";
CacheDirectoryMode = "0750";
# Logs directory and mode
LogsDirectory = "webnsupdate";
LogsDirectoryMode = "0750";
# State directory and mode
StateDirectory = "webnsupdate";
StateDirectoryMode = "0750";
# New file permissions
UMask = "0027";
# Security
NoNewPrivileges = true;
ProtectHome = true;
};
};
};
}

View file

@ -1,34 +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": {
"enabled": true
},
"nix": {
"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,104 +0,0 @@
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
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,250 +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": "1m",
"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::{Action, update_ns_records};
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

@ -4,7 +4,7 @@
//! records
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use std::path::Path;
use base64::prelude::*;
use miette::{Context, IntoDiagnostic, Result};
@ -20,48 +20,28 @@ pub struct Mkpasswd {
/// 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 process(self, args: &crate::Opts) -> Result<()> {
mkpasswd(self, args.password_file.as_deref(), &args.salt)
}
}
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()
let mut data = Vec::with_capacity(username.len() + password.len() + salt.len() + 1);
write!(data, "{username}:{password}{salt}").unwrap();
ring::digest::digest(&ring::digest::SHA256, &data)
}
pub fn mkpasswd(
Mkpasswd {
username,
password,
salt,
password_file,
}: Mkpasswd,
Mkpasswd { username, password }: Mkpasswd,
password_file: Option<&Path>,
salt: &str,
) -> miette::Result<()> {
let hash = hash_identity(&username, &password, &salt);
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 {
let Some(path) = password_file else {
println!("{encoded}");
return Ok(());
};

View file

@ -1,9 +1,52 @@
//! Deal with the DNS records
use miette::{LabeledSpan, Result, ensure, miette};
use std::path::Path;
pub fn validate_record_str(record: &str) -> Result<()> {
validate_line(0, record).map_err(|err| err.with_source_code(String::from(record)))
use miette::{ensure, miette, Context, IntoDiagnostic, LabeledSpan, NamedSource, Result};
/// Loads and verifies the records from a file
pub fn load(path: &Path) -> Result<()> {
let records = std::fs::read_to_string(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read records from {}", path.display()))?;
verify(&records, path)?;
Ok(())
}
/// Load records without verifying them
pub fn load_no_verify(path: &Path) -> Result<&'static [&'static str]> {
let records = std::fs::read_to_string(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read records from {}", path.display()))?;
if let Err(err) = verify(&records, path) {
tracing::error!("Failed to verify records: {err}");
}
// leak memory: we only do this here and it prevents a bunch of allocations
let records: &str = records.leak();
let records: Box<[&str]> = records.lines().collect();
Ok(Box::leak(records))
}
/// Verifies that a list of records is valid
pub fn verify(data: &str, path: &Path) -> Result<()> {
let mut offset = 0usize;
for line in data.lines() {
validate_line(offset, line).map_err(|err| {
err.with_source_code(NamedSource::new(
path.display().to_string(),
data.to_string(),
))
})?;
offset += line.len() + 1;
}
Ok(())
}
fn validate_line(offset: usize, line: &str) -> Result<()> {
@ -113,7 +156,7 @@ fn validate_octet(offset: usize, octet: u8) -> Result<()> {
#[cfg(test)]
mod test {
use crate::records::validate_record_str;
use crate::records::verify;
macro_rules! assert_miette_snapshot {
($diag:expr) => {{
@ -137,51 +180,104 @@ mod test {
#[test]
fn valid_records() -> miette::Result<()> {
for record in [
"example.com.",
"example.org.",
"example.net.",
"subdomain.example.com.",
] {
validate_record_str(record)?;
}
Ok(())
verify(
"\
example.com.\n\
example.org.\n\
example.net.\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_valid"),
)
}
#[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();
let err = verify(
"\
example.com.\n\
example.org.\n\
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.\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.unwrap_err();
assert_miette_snapshot!(err);
}
#[test]
fn not_fqd() {
let err = validate_record_str("example.net").unwrap_err();
let err = verify(
"\
example.com.\n\
example.org.\n\
example.net\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.unwrap_err();
assert_miette_snapshot!(err);
}
#[test]
fn empty_label() {
let err = validate_record_str("name..example.org.").unwrap_err();
let err = verify(
"\
example.com.\n\
name..example.org.\n\
example.net.\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.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();
let err = verify(
"\
example.com.\n\
name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.\n\
example.net.\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.unwrap_err();
assert_miette_snapshot!(err);
}
#[test]
fn invalid_ascii() {
let err = validate_record_str("name.this-is-not-ascii-ß.example.org.").unwrap_err();
let err = verify(
"\
example.com.\n\
name.this-is-not-aßcii.example.org.\n\
example.net.\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.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();
let err = verify(
"\
example.com.\n\
name.this-character:-is-not-allowed.example.org.\n\
example.net.\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.unwrap_err();
assert_miette_snapshot!(err);
}
}

View file

@ -6,9 +6,11 @@ expression: out
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
× empty label
╭────
1 │ name..example.org.
╭─[test_records_invalid:2:6]
1 │ example.com.
2 │ name..example.org.
· ▲
· ╰── label
3 │ example.net.
╰────
help: each label should have at least one character

View file

@ -6,9 +6,11 @@ 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.
╭─[test_records_invalid:3:1]
2 │ example.org.
3 │ 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
4 │ subdomain.example.com.
╰────
help: fully qualified domain names can be at most 255 characters long

View file

@ -6,9 +6,11 @@ expression: out
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
× invalid octet: '\xc3'
╭────
1 │ name.this-is-not-ascii-ß.example.org.
· ┬
· ╰── octet
╭─[test_records_invalid:2:19]
1 │ example.com.
2 │ name.this-is-not-aßcii.example.org.
· ┬
· ╰── octet
3 │ example.net.
╰────
help: we only accept ascii characters

View file

@ -6,9 +6,11 @@ expression: out
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
× invalid octet: ':'
╭────
1 │ name.this-character:-is-not-allowed.example.org.
╭─[test_records_invalid:2:20]
1 │ example.com.
2 │ name.this-character:-is-not-allowed.example.org.
· ┬
· ╰── octet
3 │ example.net.
╰────
help: hostnames are only allowed to contain characters in [a-zA-Z0-9_-]

View file

@ -6,9 +6,11 @@ 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.
╭─[test_records_invalid:2:6]
1 │ example.com.
2 │ name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.
· ───────────────────────────────────────┬──────────────────────────────────────
· ╰── label
3 │ example.net.
╰────
help: labels should be at most 63 octets

View file

@ -6,9 +6,11 @@ expression: out
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
× not a fully qualified domain name
╭────
1 │ example.net
╭─[test_records_invalid:3:11]
2 │ example.org.
3 │ example.net
· ┬
· ╰── last character
4 │ subdomain.example.com.
╰────
help: hostname should be a fully qualified domain name (end with a '.')