Compare commits

..

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

30 changed files with 1043 additions and 3178 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,50 +0,0 @@
on: [push]
jobs:
build:
runs-on: nixos
steps:
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: nix --version
- name: Build Package
run: |
nix build --print-build-logs .#
checks:
needs: build # we use the built binaries in the checks
runs-on: nixos
strategy:
matrix:
system:
- x86_64-linux
test:
- treefmt
- clippy
- deny
- nextest
- module-ipv4-only-test
- module-ipv4-test
- module-ipv6-only-test
- module-ipv6-test
- module-nginx-test
env:
TEST: ${{ matrix.test }}
SYSTEM: ${{ matrix.system }}
steps:
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: nix --version
- name: Run tests
run: |
nix build --print-build-logs \
.#checks."$SYSTEM"."$TEST"
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@e5d1a0751adb4963c0a4982503806ae5f19f52da" # 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,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

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

754
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,48 +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 = "2024" edition = "2021"
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 }
[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.43.1", 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,39 +1,25 @@
let
inherit (builtins.getFlake (builtins.toString ./.)) inputs;
in
{ {
pkgs ? inputs.nixpkgs.legacyPackages.${builtins.currentSystem}, lib,
lib ? pkgs.lib, rustPlatform,
crane ? inputs.crane, }: let
craneLib ? crane.mkLib pkgs, readToml = path: builtins.fromTOML (builtins.readFile path);
cargoArtifacts ? null, cargoToml = readToml ./Cargo.toml;
src ? craneLib.cleanCargoSource ./., pname = cargoToml.package.name;
mold ? pkgs.mold, inherit (cargoToml.package) version description;
}: in
let rustPlatform.buildRustPackage {
commonArgs = { inherit pname version;
inherit src; src = builtins.path {
strictDeps = true; path = ./.;
name = "${pname}-source";
doCheck = false; # tests will be run in the `checks` derivation };
NEXTEST_HIDE_PROGRESS_BAR = 1; cargoLock.lockFile = ./Cargo.lock;
NEXTEST_FAILURE_OUTPUT = "immediate-final"; useNextest = true;
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";
}; };
};
in
craneLib.buildPackage (
lib.mergeAttrsList [
commonArgs
{
cargoArtifacts =
if cargoArtifacts == null then craneLib.buildDepsOnly commonArgs else cargoArtifacts;
} }
]
)

238
deny.toml
View file

@ -1,238 +0,0 @@
# This template contains all of the possible sections and their default values
# Note that all fields that take a lint level have these possible values:
# * deny - An error will be produced and the check will fail
# * warn - A warning will be produced, but the check will not fail
# * allow - No warning or error will be produced, though in some cases a note
# will be
# The values provided in this template are the default values that will be used
# when any section or field is not specified in your own configuration
# Root options
# The graph table configures how the dependency graph is constructed and thus
# which crates the checks are performed against
[graph]
# If 1 or more target triples (and optionally, target_features) are specified,
# only the specified targets will be checked when running `cargo deny check`.
# This means, if a particular package is only ever used as a target specific
# dependency, such as, for example, the `nix` crate only being used via the
# `target_family = "unix"` configuration, that only having windows targets in
# this list would mean the nix crate, as well as any of its exclusive
# dependencies not shared by any other crates, would be ignored, as the target
# list here is effectively saying which targets you are building for.
targets = [
# The triple can be any string, but only the target triples built in to
# rustc (as of 1.40) can be checked against actual config expressions
"x86_64-unknown-linux-gnu",
# You can also specify which target_features you promise are enabled for a
# particular target. target_features are currently not validated against
# the actual valid features supported by the target architecture.
#{ triple = "wasm32-unknown-unknown", features = ["atomics"] },
]
# When creating the dependency graph used as the source of truth when checks are
# executed, this field can be used to prune crates from the graph, removing them
# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate
# is pruned from the graph, all of its dependencies will also be pruned unless
# they are connected to another crate in the graph that hasn't been pruned,
# so it should be used with care. The identifiers are [Package ID Specifications]
# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html)
#exclude = []
# If true, metadata will be collected with `--all-features`. Note that this can't
# be toggled off if true, if you want to conditionally enable `--all-features` it
# is recommended to pass `--all-features` on the cmd line instead
all-features = false
# If true, metadata will be collected with `--no-default-features`. The same
# caveat with `all-features` applies
no-default-features = false
# If set, these feature will be enabled when collecting metadata. If `--features`
# is specified on the cmd line they will take precedence over this option.
#features = []
# The output table provides options for how/if diagnostics are outputted
[output]
# When outputting inclusion graphs in diagnostics that include features, this
# option can be used to specify the depth at which feature edges will be added.
# This option is included since the graphs can be quite large and the addition
# of features from the crate(s) to all of the graph roots can be far too verbose.
# This option can be overridden via `--feature-depth` on the cmd line
feature-depth = 1
# This section is considered when running `cargo deny check advisories`
# More documentation for the advisories section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
[advisories]
# The path where the advisory databases are cloned/fetched into
#db-path = "$CARGO_HOME/advisory-dbs"
# The url(s) of the advisory databases to use
#db-urls = ["https://github.com/rustsec/advisory-db"]
# A list of advisory IDs to ignore. Note that ignored advisories will still
# output a note when they are encountered.
ignore = [
#"RUSTSEC-0000-0000",
#{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" },
#"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish
#{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" },
]
# If this is true, then cargo deny will use the git executable to fetch advisory database.
# If this is false, then it uses a built-in git library.
# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support.
# See Git Authentication for more information about setting up git authentication.
#git-fetch-with-cli = true
# This section is considered when running `cargo deny check licenses`
# More documentation for the licenses section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
[licenses]
# List of explicitly allowed licenses
# See https://spdx.org/licenses/ for list of possible licenses
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
allow = [
"ISC",
"MIT",
"Apache-2.0",
"BSD-3-Clause",
"Unicode-3.0",
#"Apache-2.0 WITH LLVM-exception",
]
# The confidence threshold for detecting a license from license text.
# The higher the value, the more closely the license text must be to the
# canonical license text of a valid SPDX license file.
# [possible values: any between 0.0 and 1.0].
confidence-threshold = 0.8
# Allow 1 or more licenses on a per-crate basis, so that particular licenses
# aren't accepted for every possible crate as with the normal allow list
exceptions = [
# Each entry is the crate and version constraint, and its specific allow
# list
#{ allow = ["Zlib"], crate = "adler32" },
]
# Some crates don't have (easily) machine readable licensing information,
# adding a clarification entry for it allows you to manually specify the
# licensing information
#[[licenses.clarify]]
# The package spec the clarification applies to
#crate = "ring"
# The SPDX expression for the license requirements of the crate
#expression = "MIT AND ISC AND OpenSSL"
# One or more files in the crate's source used as the "source of truth" for
# the license expression. If the contents match, the clarification will be used
# when running the license check, otherwise the clarification will be ignored
# and the crate will be checked normally, which may produce warnings or errors
# depending on the rest of your configuration
#license-files = [
# Each entry is a crate relative path, and the (opaque) hash of its contents
#{ path = "LICENSE", hash = 0xbd0eed23 }
#]
[licenses.private]
# If true, ignores workspace crates that aren't published, or are only
# published to private registries.
# To see how to mark a crate as unpublished (to the official registry),
# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field.
ignore = false
# One or more private registries that you might publish crates to, if a crate
# is only published to private registries, and ignore is true, the crate will
# not have its license(s) checked
registries = [
#"https://sekretz.com/registry
]
# This section is considered when running `cargo deny check bans`.
# More documentation about the 'bans' section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
[bans]
# Lint level for when multiple versions of the same crate are detected
multiple-versions = "warn"
# Lint level for when a crate version requirement is `*`
wildcards = "allow"
# The graph highlighting used when creating dotgraphs for crates
# with multiple versions
# * lowest-version - The path to the lowest versioned duplicate is highlighted
# * simplest-path - The path to the version with the fewest edges is highlighted
# * all - Both lowest-version and simplest-path are used
highlight = "all"
# The default lint level for `default` features for crates that are members of
# the workspace that is being checked. This can be overridden by allowing/denying
# `default` on a crate-by-crate basis if desired.
workspace-default-features = "allow"
# The default lint level for `default` features for external crates that are not
# members of the workspace. This can be overridden by allowing/denying `default`
# on a crate-by-crate basis if desired.
external-default-features = "allow"
# List of crates that are allowed. Use with care!
allow = [
#"ansi_term@0.11.0",
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" },
]
# List of crates to deny
deny = [
#"ansi_term@0.11.0",
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" },
# Wrapper crates can optionally be specified to allow the crate when it
# is a direct dependency of the otherwise banned crate
#{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] },
]
# List of features to allow/deny
# Each entry the name of a crate and a version range. If version is
# not specified, all versions will be matched.
#[[bans.features]]
#crate = "reqwest"
# Features to not allow
#deny = ["json"]
# Features to allow
#allow = [
# "rustls",
# "__rustls",
# "__tls",
# "hyper-rustls",
# "rustls",
# "rustls-pemfile",
# "rustls-tls-webpki-roots",
# "tokio-rustls",
# "webpki-roots",
#]
# If true, the allowed features must exactly match the enabled feature set. If
# this is set there is no point setting `deny`
#exact = true
# Certain crates/versions that will be skipped when doing duplicate detection.
skip = [
#"ansi_term@0.11.0",
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" },
]
# Similarly to `skip` allows you to skip certain crates during duplicate
# detection. Unlike skip, it also includes the entire tree of transitive
# dependencies starting at the specified crate, up to a certain depth, which is
# by default infinite.
skip-tree = [
#"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies
#{ crate = "ansi_term@0.11.0", depth = 20 },
]
# This section is considered when running `cargo deny check sources`.
# More documentation about the 'sources' section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html
[sources]
# Lint level for what to happen when a crate from a crate registry that is not
# in the allow list is encountered
unknown-registry = "warn"
# Lint level for what to happen when a crate from a git repository that is not
# in the allow list is encountered
unknown-git = "warn"
# List of URLs for allowed crate registries. Defaults to the crates.io index
# if not specified. If it is specified but empty, no registries are allowed.
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
# List of URLs for allowed Git repositories
allow-git = []
[sources.allow-org]
# github.com organizations to allow git sources for
github = []
# gitlab.com organizations to allow git sources for
gitlab = []
# bitbucket.org organizations to allow git sources for
bitbucket = []

View file

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

View file

@ -1,74 +0,0 @@
{ inputs, ... }:
let
inherit (inputs) crane;
in
{
flake.overlays.default = final: prev: {
webnsupdate = prev.callPackage ../default.nix {
inherit crane;
pkgSrc = inputs.self;
};
};
perSystem =
{
system,
pkgs,
lib,
...
}:
let
craneLib = (crane.mkLib pkgs).overrideToolchain (pkgs: pkgs.rust-bin.stable.latest.default);
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 craneLib cargoArtifacts src;
};
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;
deny = craneLib.cargoDeny commonArgs;
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;
};
};
};
};
}

97
flake.lock generated
View file

@ -1,81 +1,24 @@
{ {
"nodes": { "nodes": {
"crane": {
"locked": {
"lastModified": 1752859226,
"narHash": "sha256-Vk9qUd0pCkyJZiSDRxJBEDkxEr8CNcwBtuFuZr/HYNc=",
"owner": "ipetkov",
"repo": "crane",
"rev": "126943a6f7b7c6535c0348fe3ba472c3b19f0e20",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1751413152,
"narHash": "sha256-Tyw1RjYEsp5scoigs1384gIg6e0GoBVjms4aXFfRssQ=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "77826244401ea9de6e3bac47c2db46005e1f30b5",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1752820569, "lastModified": 1716948383,
"narHash": "sha256-okomYyezDlua8/ssPHEdE2/gC1r+MElosWlZQuFrIdQ=", "narHash": "sha256-SzDKxseEcHR5KzPXLwsemyTR/kaM9whxeiJohbL04rs=",
"rev": "6e987485eb2c77e5dcc5af4e3c70843711ef9251", "owner": "NixOS",
"type": "tarball", "repo": "nixpkgs",
"url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre831064.6e987485eb2c/nixexprs.tar.xz?rev=6e987485eb2c77e5dcc5af4e3c70843711ef9251" "rev": "ad57eef4ef0659193044870c731987a6df5cf56b",
"type": "github"
}, },
"original": { "original": {
"type": "tarball", "id": "nixpkgs",
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" "ref": "nixos-unstable",
"type": "indirect"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay", "systems": "systems"
"systems": "systems",
"treefmt-nix": "treefmt-nix"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1752806774,
"narHash": "sha256-4cHeoR2roN7d/3J6gT+l6o7J2hTrBIUiCwVdDNMeXzE=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "3c90219b3ba1c9790c45a078eae121de48a39c55",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
} }
}, },
"systems": { "systems": {
@ -92,26 +35,6 @@
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1752055615,
"narHash": "sha256-19m7P4O/Aw/6+CzncWMAJu89JaKeMh3aMle1CNQSIwM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "c9d477b5d5bd7f26adddd3f96cfd6a904768d4f9",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

View file

@ -1,28 +1,39 @@
{ {
description = "An http server that calls nsupdate internally"; description = "An http server that calls nsupdate internally";
inputs = { inputs = {
nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"; nixpkgs.url = "nixpkgs/nixos-unstable";
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
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"; systems.url = "github:nix-systems/default";
}; };
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,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

@ -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::{LabeledSpan, Result, ensure, miette};
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 '.')