Compare commits
154 commits
Author | SHA1 | Date | |
---|---|---|---|
0f4ff679d2 | |||
e28cf9be3f | |||
1dac706aeb | |||
29916c0841 | |||
3c9587bd4c | |||
7dcd2c6a4c | |||
5a98e8205f | |||
2183c81d70 | |||
4ab2f709a9 | |||
269a37c920 | |||
6ac1133486 | |||
c5c51645ba | |||
fe58c295ff | |||
f356463079 | |||
caa7c1165f | |||
7dca0efdf2 | |||
5f2ec1089f | |||
ba2babb0ae | |||
f9da4aecd4 | |||
2d00dd3818 | |||
2daf620a4a | |||
316f2bf576 | |||
3d660314cf | |||
f207cbe859 | |||
c589fb40c3 | |||
08ea3271c1 | |||
8787adae30 | |||
130c949723 | |||
e236aa424b | |||
baeb98a2e6 | |||
9b41d7d2a5 | |||
33f8b1570d | |||
06995416d2 | |||
f8848e669e | |||
72843b8f52 | |||
4cdffc20bc | |||
cf66c77136 | |||
5fc53886f2 | |||
881983dd6c | |||
632250d544 | |||
c71a8b418c | |||
873dd980ff | |||
3e1140ffe5 | |||
6c78e8d78e | |||
3ed76c094c | |||
f16c3b9138 | |||
42482574ac | |||
855963bc85 | |||
7cc182b23e | |||
bc62fd7c1d | |||
cdef8078cc | |||
a6f0785dc3 | |||
5d4c0fdb70 | |||
2a2f9ef06c | |||
ef6e955b90 | |||
b581e2adf1 | |||
48034ec6e5 | |||
593bee9024 | |||
09345f2193 | |||
71d1e43ef2 | |||
528aad1d8e | |||
cb7e4d554b | |||
01f53b2bf0 | |||
60662ff1f0 | |||
eaed7b2302 | |||
8a04c2726f | |||
1a88dbaeb2 | |||
c41008f800 | |||
3c18f07a2a | |||
0a5348097d | |||
bdb27d7cb1 | |||
41c30372fb | |||
e99bc52de2 | |||
338e296683 | |||
29f7315f67 | |||
738fa8accf | |||
172076eaad | |||
b775f8e811 | |||
48c2e5be4d | |||
d56af9ecfe | |||
72aa4f365e | |||
d8630aa8cb | |||
5655d7de67 | |||
d2f6c3cd66 | |||
13c9c544a7 | |||
70ed898f1d | |||
2f97008475 | |||
09bd450a46 | |||
a6bb8bf817 | |||
a39fa354e4 | |||
26566fd612 | |||
09be5627c3 | |||
dff29cab77 | |||
c8407a8eb4 | |||
77cb03576d | |||
a47dd0bfac | |||
34ce8a69f6 | |||
ea428d1aef | |||
98aa3c2a97 | |||
0fc17d9150 | |||
e72e3777b8 | |||
2473e6edbc | |||
989ed2a080 | |||
2e8d20f89d | |||
ec27e31336 | |||
e5f7d94f77 | |||
a2735b46b5 | |||
542336867a | |||
70162c83f6 | |||
0fd9a87907 | |||
880d462e80 | |||
40a9d600c9 | |||
8bf62f3ce2 | |||
883f6e6ae7 | |||
4863ebc6df | |||
36b4d55ea8 | |||
faf1f637ab | |||
9b2880c141 | |||
0685c2601a | |||
5e16700652 | |||
ccc1ccba97 | |||
a515c5d8df | |||
7fdf322c73 | |||
5d6fd054ee | |||
2b953c4b75 | |||
c038b68ecb | |||
dcba690961 | |||
99e887513d | |||
4490dfac05 | |||
22ab037b6f | |||
e4451beebf | |||
6a1feb2612 | |||
2ba6277778 | |||
69fde96f67 | |||
f6084449fa | |||
e9d5b87ecc | |||
63a7505724 | |||
502f7cbcdf | |||
7bc103e861 | |||
2a52e66bc0 | |||
bd8badac23 | |||
![]() |
6b4c0c4865 | ||
9f40444846 | |||
faf09c7de1 | |||
657a877168 | |||
ce2692f66d | |||
bea38bc445 | |||
846a0675d1 | |||
750cbbff93 | |||
60aed649b1 | |||
d98c4202f4 | |||
68cf43c539 | |||
![]() |
82b1078a94 | ||
5a9b09bc7e |
29 changed files with 2257 additions and 1172 deletions
14
.editorconfig
Normal file
14
.editorconfig
Normal file
|
@ -0,0 +1,14 @@
|
|||
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
|
|
@ -1,14 +1,41 @@
|
|||
on: [push]
|
||||
jobs:
|
||||
check:
|
||||
check-renovaterc:
|
||||
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
|
||||
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Validate renovaterc
|
||||
run: |
|
||||
nix --version
|
||||
nix shell nixpkgs#renovate --command renovate-config-validator
|
||||
build:
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- uses: https://git.salame.cl/actions/checkout@v4
|
||||
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Build Package
|
||||
run: |
|
||||
nix --version
|
||||
nix build --print-build-logs .#
|
||||
test:
|
||||
needs: build # we use the built binaries in the checks
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Run tests
|
||||
run: |
|
||||
nix --version
|
||||
nix-fast-build --max-jobs 2 --no-nom --skip-cached --no-link \
|
||||
--flake ".#checks.$(nix eval --raw --impure --expr builtins.currentSystem)"
|
||||
report-size:
|
||||
runs-on: nixos
|
||||
needs: build
|
||||
steps:
|
||||
- uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- run: nix --version
|
||||
- run: nix build --print-build-logs .#
|
||||
- name: Generate size report
|
||||
uses: "https://git.salame.cl/jalil/nix-flake-outputs-size@5c40a31e3e2ed0ea28f8ba68deca41d05fdf2e71" # main
|
||||
with:
|
||||
comment-on-pr: ${{ github.ref_name != 'main' }}
|
||||
generate-artifact: ${{ github.ref_name == 'main' }}
|
||||
do-comparison: true
|
||||
job-name: report-size
|
||||
|
|
|
@ -1,62 +0,0 @@
|
|||
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"
|
44
.renovaterc.json
Normal file
44
.renovaterc.json
Normal file
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"assignees": [
|
||||
"jalil"
|
||||
],
|
||||
"automerge": true,
|
||||
"automergeStrategy": "auto",
|
||||
"automergeType": "pr",
|
||||
"commitBodyTable": true,
|
||||
"dependencyDashboard": true,
|
||||
"extends": [
|
||||
"config:best-practices"
|
||||
],
|
||||
"prCreation": "immediate",
|
||||
"cargo": {
|
||||
"commitMessageTopic": "Rust crate {{depName}}",
|
||||
"fileMatch": [
|
||||
"(^|/)Cargo\\.toml$"
|
||||
],
|
||||
"versioning": "cargo",
|
||||
"enabled": true
|
||||
},
|
||||
"nix": {
|
||||
"fileMatch": [
|
||||
"(^|/)flake\\.nix$"
|
||||
],
|
||||
"commitMessageTopic": "nixpkgs",
|
||||
"commitMessageExtra": "to {{newValue}}",
|
||||
"enabled": true
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"recreateWhen": "always",
|
||||
"rebaseWhen": "behind-base-branch",
|
||||
"branchTopic": "lock-file-maintenance",
|
||||
"commitMessageAction": "Lock file maintenance",
|
||||
"schedule": [
|
||||
"* 22 * * *"
|
||||
]
|
||||
},
|
||||
"automergeSchedule": [
|
||||
"* 23 * * *"
|
||||
]
|
||||
}
|
139
CHANGELOG.md
Normal file
139
CHANGELOG.md
Normal file
|
@ -0,0 +1,139 @@
|
|||
# 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 -->
|
533
Cargo.lock
generated
533
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
47
Cargo.toml
47
Cargo.toml
|
@ -1,35 +1,48 @@
|
|||
cargo-features = ["codegen-backend"]
|
||||
|
||||
[package]
|
||||
description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate"
|
||||
name = "webnsupdate"
|
||||
version = "0.3.1"
|
||||
version = "0.3.6"
|
||||
edition = "2021"
|
||||
license-file = "LICENSE"
|
||||
readme = "README.md"
|
||||
keywords = ["dns", "dyndns", "dynamic-ip"]
|
||||
categories = ["networking", "dns", "dyndns"]
|
||||
repository = "https://github.com/jalil-salame/webnsupdate"
|
||||
|
||||
[lints.clippy]
|
||||
cargo = { level = "warn", priority = -2 }
|
||||
multiple_crate_versions = "allow"
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
axum-auth = { version = "0.7", default-features = false, features = [
|
||||
"auth-basic",
|
||||
] }
|
||||
axum-client-ip = "0.6"
|
||||
axum = "0.8"
|
||||
axum-client-ip = "1.0"
|
||||
base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
clap-verbosity-flag = "2"
|
||||
clap-verbosity-flag = { version = "3", default-features = false, features = [
|
||||
"tracing",
|
||||
] }
|
||||
http = "1"
|
||||
humantime = "2.2.0"
|
||||
miette = { version = "7", features = ["fancy"] }
|
||||
ring = { version = "0.17", features = ["std"] }
|
||||
tokio = { version = "1", features = [
|
||||
"macros",
|
||||
"rt",
|
||||
"process",
|
||||
"io-util",
|
||||
] }
|
||||
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]
|
||||
insta = "1"
|
||||
insta = { version = "=1.42.2", features = ["json"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[profile.dev]
|
||||
debug = 0
|
||||
codegen-backend = "cranelift"
|
||||
|
|
85
cliff.toml
Normal file
85
cliff.toml
Normal file
|
@ -0,0 +1,85 @@
|
|||
# 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"
|
37
default.nix
Normal file
37
default.nix
Normal file
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
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; }
|
||||
]
|
||||
)
|
|
@ -3,10 +3,18 @@
|
|||
imports = [
|
||||
inputs.treefmt-nix.flakeModule
|
||||
./package.nix
|
||||
./module.nix
|
||||
./tests.nix
|
||||
];
|
||||
|
||||
flake.nixosModules =
|
||||
let
|
||||
webnsupdate = ../module.nix;
|
||||
in
|
||||
{
|
||||
default = webnsupdate;
|
||||
inherit webnsupdate;
|
||||
};
|
||||
|
||||
perSystem =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
|
@ -22,10 +30,11 @@
|
|||
};
|
||||
|
||||
devShells.default = pkgs.mkShellNoCC {
|
||||
packages = [
|
||||
pkgs.cargo-insta
|
||||
pkgs.cargo-udeps
|
||||
pkgs.mold
|
||||
packages = with pkgs; [
|
||||
cargo-insta
|
||||
cargo-udeps
|
||||
mold
|
||||
git-cliff
|
||||
];
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,196 +0,0 @@
|
|||
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;
|
||||
};
|
||||
}
|
|
@ -1,13 +1,11 @@
|
|||
{ withSystem, inputs, ... }:
|
||||
{ inputs, ... }:
|
||||
{
|
||||
flake.overlays.default =
|
||||
final: prev:
|
||||
withSystem prev.stdenv.hostPlatform.system (
|
||||
{ self', ... }:
|
||||
{
|
||||
inherit (self'.packages) webnsupdate;
|
||||
}
|
||||
);
|
||||
flake.overlays.default = final: prev: {
|
||||
webnsupdate = prev.callPackage ../default.nix {
|
||||
inherit (inputs) crane;
|
||||
pkgSrc = inputs.self;
|
||||
};
|
||||
};
|
||||
|
||||
perSystem =
|
||||
{ pkgs, lib, ... }:
|
||||
|
@ -33,46 +31,30 @@
|
|||
};
|
||||
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
webnsupdate = craneLib.buildPackage (
|
||||
lib.mergeAttrsList [
|
||||
commonArgs
|
||||
{ inherit cargoArtifacts; }
|
||||
]
|
||||
);
|
||||
withArtifacts = lib.mergeAttrsList [
|
||||
commonArgs
|
||||
{ inherit cargoArtifacts; }
|
||||
];
|
||||
webnsupdate = pkgs.callPackage ../default.nix {
|
||||
inherit (inputs) crane;
|
||||
pkgSrc = inputs.self;
|
||||
};
|
||||
in
|
||||
{
|
||||
checks = {
|
||||
nextest = craneLib.cargoNextest withArtifacts;
|
||||
clippy = craneLib.cargoClippy (
|
||||
lib.mergeAttrsList [
|
||||
commonArgs
|
||||
{
|
||||
inherit cargoArtifacts;
|
||||
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
nextest = craneLib.cargoNextest (
|
||||
lib.mergeAttrsList [
|
||||
commonArgs
|
||||
{ inherit cargoArtifacts; }
|
||||
withArtifacts
|
||||
{ cargoClippyExtraArgs = "--all-targets -- --deny warnings"; }
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,138 +6,337 @@
|
|||
checks =
|
||||
let
|
||||
testDomain = "webnstest.example";
|
||||
dynamicZonesDir = "/var/lib/named/zones";
|
||||
zoneFile = pkgs.writeText "${testDomain}.zoneinfo" ''
|
||||
$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)
|
||||
lastIPPath = "/var/lib/webnsupdate/last-ip.json";
|
||||
|
||||
IN NS ns1.${testDomain}.
|
||||
zoneFile = pkgs.writeText "${testDomain}.zoneinfo" ''
|
||||
$TTL 600 ; 10 minutes
|
||||
$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
|
||||
@ 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
|
||||
'';
|
||||
|
||||
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; };
|
||||
'';
|
||||
};
|
||||
};
|
||||
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
|
||||
|
||||
${pkgs.coreutils}/bin/mkdir -m 0755 -p ${dynamicZonesDir}
|
||||
chown "named" ${dynamicZonesDir}
|
||||
chown "named" /var/lib/named
|
||||
|
||||
# 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-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}")
|
||||
'';
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
29
flake.lock
generated
29
flake.lock
generated
|
@ -2,11 +2,11 @@
|
|||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1730060262,
|
||||
"narHash": "sha256-RMgSVkZ9H03sxC+Vh4jxtLTCzSjPq18UWpiM0gq6shQ=",
|
||||
"lastModified": 1743700120,
|
||||
"narHash": "sha256-8BjG/P0xnuCyVOXlYRwdI1B8nVtyYLf3oDwPSimqREY=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "498d9f122c413ee1154e8131ace5a35a80d8fa76",
|
||||
"rev": "e316f19ee058e6db50075115783be57ac549c389",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -22,11 +22,11 @@
|
|||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1727826117,
|
||||
"narHash": "sha256-K5ZLCyfO/Zj9mPFldf3iwS6oZStJcU4tSpiXTMYaaL0=",
|
||||
"lastModified": 1743550720,
|
||||
"narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "3d04084d54bedc3d6b8b736c70ef449225c361b1",
|
||||
"rev": "c621e8422220273271f52058f618c94e405bb0f5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -37,17 +37,18 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1728492678,
|
||||
"narHash": "sha256-9UTxR8eukdg+XZeHgxW5hQA9fIKHsKCdOIUycTryeVw=",
|
||||
"lastModified": 1743583204,
|
||||
"narHash": "sha256-F7n4+KOIfWrwoQjXrL2wD9RhFYLs2/GGe/MQY1sSdlE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5633bcff0c6162b9e4b5f1264264611e950c8ec7",
|
||||
"rev": "2c8d3f48d33929642c1c12cd243df4cc7d2ce434",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"type": "indirect"
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
|
@ -81,11 +82,11 @@
|
|||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1729613947,
|
||||
"narHash": "sha256-XGOvuIPW1XRfPgHtGYXd5MAmJzZtOuwlfKDgxX5KT3s=",
|
||||
"lastModified": 1743677901,
|
||||
"narHash": "sha256-eWZln+k+L/VHO69tUTzEmgeDWNQNKIpSUa9nqQgBrSE=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "aac86347fb5063960eccb19493e0cadcdb4205ca",
|
||||
"rev": "57dabe2a6255bd6165b2437ff6c2d1f6ee78421a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
url = "github:hercules-ci/flake-parts";
|
||||
inputs.nixpkgs-lib.follows = "nixpkgs";
|
||||
};
|
||||
nixpkgs.url = "nixpkgs/nixos-unstable";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
systems.url = "github:nix-systems/default";
|
||||
treefmt-nix = {
|
||||
url = "github:numtide/treefmt-nix";
|
||||
|
|
2
justfile
Normal file
2
justfile
Normal file
|
@ -0,0 +1,2 @@
|
|||
changelog version:
|
||||
git cliff --unreleased --prepend=CHANGELOG.md --tag='{{ version }}'
|
162
module.nix
Normal file
162
module.nix
Normal file
|
@ -0,0 +1,162 @@
|
|||
{ 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
104
src/auth.rs
Normal file
104
src/auth.rs
Normal file
|
@ -0,0 +1,104 @@
|
|||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use tower_http::validate_request::ValidateRequestHeaderLayer;
|
||||
use tracing::{trace, warn};
|
||||
|
||||
use crate::password;
|
||||
|
||||
pub fn layer<'a, ResBody>(
|
||||
user_pass_hash: &'a [u8],
|
||||
salt: &'a str,
|
||||
) -> ValidateRequestHeaderLayer<Basic<'a, ResBody>> {
|
||||
ValidateRequestHeaderLayer::custom(Basic::new(user_pass_hash, salt))
|
||||
}
|
||||
|
||||
#[derive(Copy)]
|
||||
pub struct Basic<'a, ResBody> {
|
||||
pass: &'a [u8],
|
||||
salt: &'a str,
|
||||
_ty: std::marker::PhantomData<fn() -> ResBody>,
|
||||
}
|
||||
|
||||
impl<ResBody> std::fmt::Debug for Basic<'_, ResBody> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BasicAuth")
|
||||
.field("pass", &self.pass)
|
||||
.field("salt", &self.salt)
|
||||
.field("_ty", &self._ty)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<ResBody> Clone for Basic<'_, ResBody> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
pass: self.pass,
|
||||
salt: self.salt,
|
||||
_ty: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, ResBody> Basic<'a, ResBody> {
|
||||
pub fn new(pass: &'a [u8], salt: &'a str) -> Self {
|
||||
Self {
|
||||
pass,
|
||||
salt,
|
||||
_ty: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_headers(&self, headers: &http::HeaderMap<http::HeaderValue>) -> bool {
|
||||
let Some(auth) = headers.get(http::header::AUTHORIZATION) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Poor man's split once: https://doc.rust-lang.org/std/primitive.slice.html#method.split_once
|
||||
let Some(index) = auth.as_bytes().iter().position(|&c| c == b' ') else {
|
||||
return false;
|
||||
};
|
||||
let user_pass = &auth.as_bytes()[index + 1..];
|
||||
|
||||
match base64::engine::general_purpose::URL_SAFE.decode(user_pass) {
|
||||
Ok(user_pass) => {
|
||||
let hashed = password::hash_basic_auth(&user_pass, self.salt);
|
||||
if hashed.as_ref() == self.pass {
|
||||
return true;
|
||||
}
|
||||
warn!("rejected update");
|
||||
trace!(
|
||||
"mismatched hashes:\nprovided: {}\nstored: {}",
|
||||
URL_SAFE_NO_PAD.encode(hashed.as_ref()),
|
||||
URL_SAFE_NO_PAD.encode(self.pass),
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("received invalid base64 when decoding Basic header: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<B, ResBody> tower_http::validate_request::ValidateRequest<B> for Basic<'_, ResBody>
|
||||
where
|
||||
ResBody: Default,
|
||||
{
|
||||
type ResponseBody = ResBody;
|
||||
|
||||
fn validate(
|
||||
&mut self,
|
||||
request: &mut http::Request<B>,
|
||||
) -> std::result::Result<(), http::Response<Self::ResponseBody>> {
|
||||
if self.check_headers(request.headers()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut res = http::Response::new(ResBody::default());
|
||||
*res.status_mut() = http::status::StatusCode::UNAUTHORIZED;
|
||||
res.headers_mut()
|
||||
.insert(http::header::WWW_AUTHENTICATE, "Basic".parse().unwrap());
|
||||
Err(res)
|
||||
}
|
||||
}
|
253
src/config.rs
Normal file
253
src/config.rs
Normal file
|
@ -0,0 +1,253 @@
|
|||
use std::{
|
||||
fs::File,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use axum_client_ip::ClientIpSource;
|
||||
use miette::{Context, IntoDiagnostic};
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, serde::Deserialize, serde::Serialize)]
|
||||
pub enum IpType {
|
||||
#[default]
|
||||
Both,
|
||||
Ipv4Only,
|
||||
Ipv6Only,
|
||||
}
|
||||
|
||||
impl IpType {
|
||||
pub fn valid_for_type(self, ip: IpAddr) -> bool {
|
||||
match self {
|
||||
IpType::Both => true,
|
||||
IpType::Ipv4Only => ip.is_ipv4(),
|
||||
IpType::Ipv6Only => ip.is_ipv6(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IpType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
IpType::Both => f.write_str("both"),
|
||||
IpType::Ipv4Only => f.write_str("ipv4-only"),
|
||||
IpType::Ipv6Only => f.write_str("ipv6-only"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for IpType {
|
||||
type Err = miette::Error;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s {
|
||||
"both" => Ok(Self::Both),
|
||||
"ipv4-only" => Ok(Self::Ipv4Only),
|
||||
"ipv6-only" => Ok(Self::Ipv6Only),
|
||||
_ => miette::bail!("expected one of 'ipv4-only', 'ipv6-only' or 'both', got '{s}'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Webserver settings
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Server {
|
||||
/// Ip address and port of the server
|
||||
#[serde(default = "default_address")]
|
||||
pub address: SocketAddr,
|
||||
}
|
||||
|
||||
/// Password settings
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Password {
|
||||
/// File containing password to match against
|
||||
///
|
||||
/// Should be of the format `username:password` and contain a single password
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub password_file: Option<PathBuf>,
|
||||
|
||||
/// Salt to get more unique hashed passwords and prevent table based attacks
|
||||
#[serde(default = "default_salt")]
|
||||
pub salt: Box<str>,
|
||||
}
|
||||
|
||||
/// Records settings
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Records {
|
||||
/// Time To Live (in seconds) to set on the DNS records
|
||||
#[serde(
|
||||
default = "default_ttl",
|
||||
serialize_with = "humantime_ser",
|
||||
deserialize_with = "humantime_de"
|
||||
)]
|
||||
pub ttl: humantime::Duration,
|
||||
|
||||
/// List of domain names for which to update the IP when an update is requested
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub records: Vec<Box<str>>,
|
||||
|
||||
/// If provided, when an IPv6 prefix is provided with an update, this will be used to derive
|
||||
/// the full IPv6 address of the client
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_id: Option<Ipv6Addr>,
|
||||
|
||||
/// If a client id is provided the ipv6 update will be ignored (only the prefix will be used).
|
||||
/// This domain will point to the ipv6 address instead of the address derived from the client
|
||||
/// id (usually this is the router).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub router_domain: Option<Box<str>>,
|
||||
|
||||
/// Set client IP source
|
||||
///
|
||||
/// see: <https://docs.rs/axum-client-ip/latest/axum_client_ip/enum.ClientIpSource.html>
|
||||
#[serde(default = "default_ip_source")]
|
||||
pub ip_source: ClientIpSource,
|
||||
|
||||
/// Set which IPs to allow updating (ipv4, ipv6 or both)
|
||||
#[serde(default = "default_ip_type")]
|
||||
pub ip_type: IpType,
|
||||
|
||||
/// Keyfile `nsupdate` should use
|
||||
///
|
||||
/// If specified, then `webnsupdate` must have read access to the file
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Config {
|
||||
/// Server Configuration
|
||||
#[serde(flatten)]
|
||||
pub server: Server,
|
||||
|
||||
/// Password Configuration
|
||||
#[serde(flatten)]
|
||||
pub password: Password,
|
||||
|
||||
/// Records Configuration
|
||||
#[serde(flatten)]
|
||||
pub records: Records,
|
||||
|
||||
/// The config schema (used for lsp completions)
|
||||
#[serde(default, rename = "$schema", skip_serializing)]
|
||||
pub _schema: serde::de::IgnoredAny,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load the configuration without verifying it
|
||||
pub fn load(path: &std::path::Path) -> miette::Result<Self> {
|
||||
serde_json::from_reader::<File, Self>(
|
||||
File::open(path)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("failed open {}", path.display()))?,
|
||||
)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("failed to load configuration from {}", path.display()))
|
||||
}
|
||||
|
||||
/// Ensure only a verified configuration is returned
|
||||
pub fn verified(self) -> miette::Result<Self> {
|
||||
self.verify()?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Verify the configuration
|
||||
pub fn verify(&self) -> Result<(), Invalid> {
|
||||
let mut invalid_records: Vec<miette::Error> = self
|
||||
.records
|
||||
.records
|
||||
.iter()
|
||||
.filter_map(|record| crate::records::validate_record_str(record).err())
|
||||
.collect();
|
||||
|
||||
invalid_records.extend(
|
||||
self.records
|
||||
.router_domain
|
||||
.as_ref()
|
||||
.and_then(|domain| crate::records::validate_record_str(domain).err()),
|
||||
);
|
||||
|
||||
let err = Invalid { invalid_records };
|
||||
|
||||
if err.invalid_records.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
|
||||
#[error("the configuration was invalid")]
|
||||
pub struct Invalid {
|
||||
#[related]
|
||||
pub invalid_records: Vec<miette::Error>,
|
||||
}
|
||||
|
||||
// --- Default Values (sadly serde doesn't have a way to specify a constant as a default value) ---
|
||||
|
||||
fn default_ttl() -> humantime::Duration {
|
||||
super::DEFAULT_TTL.into()
|
||||
}
|
||||
|
||||
fn default_salt() -> Box<str> {
|
||||
super::DEFAULT_SALT.into()
|
||||
}
|
||||
|
||||
fn default_address() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 5353)
|
||||
}
|
||||
|
||||
fn default_ip_source() -> ClientIpSource {
|
||||
ClientIpSource::RightmostXForwardedFor
|
||||
}
|
||||
|
||||
fn default_ip_type() -> IpType {
|
||||
IpType::Both
|
||||
}
|
||||
|
||||
fn humantime_de<'de, D>(de: D) -> Result<humantime::Duration, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct Visitor;
|
||||
impl serde::de::Visitor<'_> for Visitor {
|
||||
type Value = humantime::Duration;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(formatter, "a duration (e.g. 5s)")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
v.parse().map_err(E::custom)
|
||||
}
|
||||
}
|
||||
de.deserialize_str(Visitor)
|
||||
}
|
||||
|
||||
fn humantime_ser<S>(duration: &humantime::Duration, ser: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
ser.serialize_str(&duration.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_values_config_snapshot() {
|
||||
let config: Config = serde_json::from_str("{}").unwrap();
|
||||
insta::assert_json_snapshot!(config, @r#"
|
||||
{
|
||||
"address": "127.0.0.1:5353",
|
||||
"salt": "UpdateMyDNS",
|
||||
"ttl": {
|
||||
"secs": 60,
|
||||
"nanos": 0
|
||||
},
|
||||
"ip_source": "RightmostXForwardedFor",
|
||||
"ip_type": "Both"
|
||||
}
|
||||
"#);
|
||||
}
|
803
src/main.rs
803
src/main.rs
|
@ -1,24 +1,28 @@
|
|||
use std::{
|
||||
ffi::OsStr,
|
||||
io::ErrorKind,
|
||||
net::{IpAddr, SocketAddr},
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
path::{Path, PathBuf},
|
||||
process::{ExitStatus, Stdio},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{extract::State, routing::get, Json, Router};
|
||||
use axum_auth::AuthBasic;
|
||||
use axum_client_ip::{SecureClientIp, SecureClientIpSource};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum_client_ip::ClientIp;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap_verbosity_flag::Verbosity;
|
||||
use config::Config;
|
||||
use http::StatusCode;
|
||||
use miette::{bail, ensure, Context, IntoDiagnostic, Result};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tracing::{debug, error, info, level_filters::LevelFilter, trace, warn};
|
||||
use tracing::{debug, error, info};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
mod nsupdate;
|
||||
mod password;
|
||||
mod records;
|
||||
|
||||
|
@ -28,77 +32,54 @@ const DEFAULT_SALT: &str = "UpdateMyDNS";
|
|||
#[derive(Debug, Parser)]
|
||||
struct Opts {
|
||||
#[command(flatten)]
|
||||
verbosity: Verbosity,
|
||||
|
||||
/// Ip address of the server
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
address: IpAddr,
|
||||
|
||||
/// Port of the server
|
||||
#[arg(long, default_value_t = 5353)]
|
||||
port: u16,
|
||||
|
||||
/// File containing password to match against
|
||||
///
|
||||
/// Should be of the format `username:password` and contain a single password
|
||||
#[arg(long)]
|
||||
password_file: Option<PathBuf>,
|
||||
|
||||
/// Salt to get more unique hashed passwords and prevent table based attacks
|
||||
#[arg(long, default_value = DEFAULT_SALT)]
|
||||
salt: String,
|
||||
|
||||
/// Time To Live (in seconds) to set on the DNS records
|
||||
#[arg(long, default_value_t = DEFAULT_TTL.as_secs())]
|
||||
ttl: u64,
|
||||
verbosity: Verbosity<clap_verbosity_flag::InfoLevel>,
|
||||
|
||||
/// Data directory
|
||||
#[arg(long, default_value = ".")]
|
||||
#[arg(long, env, default_value = ".")]
|
||||
data_dir: PathBuf,
|
||||
|
||||
/// File containing the records that should be updated when an update request is made
|
||||
///
|
||||
/// There should be one record per line:
|
||||
///
|
||||
/// ```text
|
||||
/// example.com.
|
||||
/// mail.example.com.
|
||||
/// ```
|
||||
#[arg(long)]
|
||||
records: PathBuf,
|
||||
|
||||
/// Keyfile `nsupdate` should use
|
||||
///
|
||||
/// If specified, then `webnsupdate` must have read access to the file
|
||||
#[arg(long)]
|
||||
key_file: Option<PathBuf>,
|
||||
|
||||
/// Allow not setting a password
|
||||
#[arg(long)]
|
||||
insecure: bool,
|
||||
|
||||
/// Set client IP source
|
||||
///
|
||||
/// see: https://docs.rs/axum-client-ip/latest/axum_client_ip/enum.SecureClientIpSource.html
|
||||
#[clap(long, default_value = "RightmostXForwardedFor")]
|
||||
ip_source: SecureClientIpSource,
|
||||
#[clap(flatten)]
|
||||
config_or_command: ConfigOrCommand,
|
||||
}
|
||||
|
||||
#[derive(clap::Args, Debug)]
|
||||
#[group(multiple = false)]
|
||||
struct ConfigOrCommand {
|
||||
/// Path to the configuration file
|
||||
#[arg(long, short)]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
#[clap(subcommand)]
|
||||
subcommand: Option<Cmd>,
|
||||
}
|
||||
|
||||
impl ConfigOrCommand {
|
||||
pub fn take(&mut self) -> (Option<PathBuf>, Option<Cmd>) {
|
||||
(self.config.take(), self.subcommand.take())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Cmd {
|
||||
Mkpasswd(password::Mkpasswd),
|
||||
/// Verify the records file
|
||||
Verify,
|
||||
/// Verify the configuration file
|
||||
Verify {
|
||||
/// Path to the configuration file
|
||||
config: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl Cmd {
|
||||
pub fn process(self, args: &Opts) -> Result<()> {
|
||||
match self {
|
||||
Cmd::Mkpasswd(mkpasswd) => mkpasswd.process(args),
|
||||
Cmd::Verify => records::load(&args.records).map(drop),
|
||||
Cmd::Verify { config } => config::Config::load(&config) // load config
|
||||
.and_then(Config::verified) // verify config
|
||||
.map(drop), // ignore config data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -108,23 +89,123 @@ struct AppState<'a> {
|
|||
/// TTL set on the Zonefile
|
||||
ttl: Duration,
|
||||
|
||||
/// Salt added to the password
|
||||
salt: &'a str,
|
||||
|
||||
/// The IN A/AAAA records that should have their IPs updated
|
||||
records: &'a [&'a str],
|
||||
|
||||
/// The TSIG key file
|
||||
key_file: Option<&'a Path>,
|
||||
|
||||
/// The password hash
|
||||
password_hash: Option<&'a [u8]>,
|
||||
|
||||
/// The file where the last IP is stored
|
||||
ip_file: &'a Path,
|
||||
|
||||
/// Last recorded IPs
|
||||
last_ips: std::sync::Arc<tokio::sync::Mutex<SavedIPs>>,
|
||||
|
||||
/// The IP type for which to allow updates
|
||||
ip_type: config::IpType,
|
||||
}
|
||||
|
||||
fn load_ip(path: &Path) -> Result<Option<IpAddr>> {
|
||||
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct SavedIPs {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ipv4: Option<Ipv4Addr>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ipv6: Option<Ipv6Addr>,
|
||||
}
|
||||
|
||||
impl SavedIPs {
|
||||
fn update(&mut self, ip: IpAddr) {
|
||||
match ip {
|
||||
IpAddr::V4(ipv4_addr) => self.ipv4 = Some(ipv4_addr),
|
||||
IpAddr::V6(ipv6_addr) => self.ipv6 = Some(ipv6_addr),
|
||||
}
|
||||
}
|
||||
|
||||
fn ips(&self) -> impl Iterator<Item = IpAddr> {
|
||||
self.ipv4
|
||||
.map(IpAddr::V4)
|
||||
.into_iter()
|
||||
.chain(self.ipv6.map(IpAddr::V6))
|
||||
}
|
||||
|
||||
fn from_str(data: &str) -> miette::Result<Self> {
|
||||
match data.parse::<IpAddr>() {
|
||||
// Old format
|
||||
Ok(IpAddr::V4(ipv4)) => Ok(Self {
|
||||
ipv4: Some(ipv4),
|
||||
ipv6: None,
|
||||
}),
|
||||
Ok(IpAddr::V6(ipv6)) => Ok(Self {
|
||||
ipv4: None,
|
||||
ipv6: Some(ipv6),
|
||||
}),
|
||||
Err(_) => serde_json::from_str(data).into_diagnostic(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState<'static> {
|
||||
fn from_args(args: &Opts, config: &config::Config) -> miette::Result<Self> {
|
||||
let Opts {
|
||||
verbosity: _,
|
||||
data_dir,
|
||||
insecure,
|
||||
config_or_command: _,
|
||||
} = args;
|
||||
|
||||
let config::Records {
|
||||
ttl,
|
||||
records,
|
||||
client_id: _,
|
||||
router_domain: _,
|
||||
ip_source: _,
|
||||
ip_type,
|
||||
key_file,
|
||||
} = &config.records;
|
||||
|
||||
// Use last registered IP address if available
|
||||
let ip_file = Box::leak(data_dir.join("last-ip.json").into_boxed_path());
|
||||
|
||||
// Leak DNS records
|
||||
let records: &[&str] = &*Vec::leak(
|
||||
records
|
||||
.iter()
|
||||
.map(|record| &*Box::leak(record.clone()))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let state = AppState {
|
||||
ttl: **ttl,
|
||||
records,
|
||||
// Load keyfile
|
||||
key_file: key_file
|
||||
.as_deref()
|
||||
.map(|path| -> miette::Result<_> {
|
||||
std::fs::File::open(path)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| {
|
||||
format!("{} is not readable by the current user", path.display())
|
||||
})?;
|
||||
Ok(&*Box::leak(path.into()))
|
||||
})
|
||||
.transpose()?,
|
||||
ip_file,
|
||||
ip_type: *ip_type,
|
||||
last_ips: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||
load_ip(ip_file)?.unwrap_or_default(),
|
||||
)),
|
||||
};
|
||||
|
||||
ensure!(
|
||||
state.key_file.is_some() || *insecure,
|
||||
"a key file must be used"
|
||||
);
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
}
|
||||
|
||||
fn load_ip(path: &Path) -> Result<Option<SavedIPs>> {
|
||||
debug!("loading last IP from {}", path.display());
|
||||
let data = match std::fs::read_to_string(path) {
|
||||
Ok(ip) => ip,
|
||||
|
@ -138,120 +219,117 @@ fn load_ip(path: &Path) -> Result<Option<IpAddr>> {
|
|||
}
|
||||
};
|
||||
|
||||
Ok(Some(
|
||||
data.parse()
|
||||
.into_diagnostic()
|
||||
.wrap_err("failed to parse last ip address")?,
|
||||
))
|
||||
SavedIPs::from_str(&data)
|
||||
.wrap_err_with(|| format!("failed to load last ip address from {}", path.display()))
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Ipv6Prefix {
|
||||
prefix: Ipv6Addr,
|
||||
length: u32,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Ipv6Prefix {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let Self { prefix, length } = self;
|
||||
write!(f, "{prefix}/{length}")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Ipv6Prefix {
|
||||
type Err = miette::Error;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
let (addr, len) = s.split_once('/').wrap_err("missing `/` in ipv6 prefix")?;
|
||||
Ok(Self {
|
||||
prefix: addr
|
||||
.parse()
|
||||
.into_diagnostic()
|
||||
.wrap_err("invalid ipv6 address for ipv6 prefix")?,
|
||||
length: len
|
||||
.parse()
|
||||
.into_diagnostic()
|
||||
.wrap_err("invalid length for ipv6 prefix")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(err)]
|
||||
fn main() -> Result<()> {
|
||||
// set panic hook to pretty print with miette's formatter
|
||||
miette::set_panic_hook();
|
||||
|
||||
// parse cli arguments
|
||||
let mut args = Opts::parse();
|
||||
debug!("{args:?}");
|
||||
|
||||
// configure logger
|
||||
let subscriber = tracing_subscriber::FmtSubscriber::builder()
|
||||
.without_time()
|
||||
.with_env_filter(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(
|
||||
if args.verbosity.is_present() {
|
||||
match args.verbosity.log_level_filter() {
|
||||
clap_verbosity_flag::LevelFilter::Off => LevelFilter::OFF,
|
||||
clap_verbosity_flag::LevelFilter::Error => LevelFilter::ERROR,
|
||||
clap_verbosity_flag::LevelFilter::Warn => LevelFilter::WARN,
|
||||
clap_verbosity_flag::LevelFilter::Info => LevelFilter::INFO,
|
||||
clap_verbosity_flag::LevelFilter::Debug => LevelFilter::DEBUG,
|
||||
clap_verbosity_flag::LevelFilter::Trace => LevelFilter::TRACE,
|
||||
}
|
||||
} else {
|
||||
LevelFilter::WARN
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.with_default_directive(args.verbosity.tracing_level_filter().into())
|
||||
.from_env_lossy(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.into_diagnostic()
|
||||
.wrap_err("setting global tracing subscriber")?;
|
||||
.wrap_err("failed to set global tracing subscriber")?;
|
||||
|
||||
// process subcommand
|
||||
if let Some(cmd) = args.subcommand.take() {
|
||||
return cmd.process(&args);
|
||||
}
|
||||
debug!("{args:?}");
|
||||
|
||||
let config = match args.config_or_command.take() {
|
||||
// process subcommand
|
||||
(None, Some(cmd)) => return cmd.process(&args),
|
||||
(Some(path), None) => {
|
||||
let config = config::Config::load(&path)?;
|
||||
if let Err(err) = config.verify() {
|
||||
error!("failed to verify configuration: {err}");
|
||||
}
|
||||
config
|
||||
}
|
||||
(None, None) | (Some(_), Some(_)) => unreachable!(
|
||||
"bad state, one of config or subcommand should be available (clap should enforce this)"
|
||||
),
|
||||
};
|
||||
|
||||
// Initialize state
|
||||
let state = AppState::from_args(&args, &config)?;
|
||||
|
||||
let Opts {
|
||||
verbosity: _,
|
||||
address: ip,
|
||||
port,
|
||||
password_file,
|
||||
data_dir,
|
||||
key_file,
|
||||
data_dir: _,
|
||||
insecure,
|
||||
subcommand: _,
|
||||
records,
|
||||
salt,
|
||||
ttl,
|
||||
ip_source,
|
||||
config_or_command: _,
|
||||
} = args;
|
||||
|
||||
info!("checking environment");
|
||||
|
||||
// Set state
|
||||
let ttl = Duration::from_secs(ttl);
|
||||
// Load password hash
|
||||
let password_hash = config
|
||||
.password
|
||||
.password_file
|
||||
.map(|path| -> miette::Result<_> {
|
||||
let path = path.as_path();
|
||||
let pass = std::fs::read_to_string(path).into_diagnostic()?;
|
||||
|
||||
// Use last registered IP address if available
|
||||
let ip_file = data_dir.join("last-ip");
|
||||
let pass: Box<[u8]> = URL_SAFE_NO_PAD
|
||||
.decode(pass.trim().as_bytes())
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("failed to decode password from {}", path.display()))?
|
||||
.into();
|
||||
|
||||
let state = AppState {
|
||||
ttl,
|
||||
salt: salt.leak(),
|
||||
// Load DNS records
|
||||
records: records::load_no_verify(&records)?,
|
||||
// Load keyfile
|
||||
key_file: key_file
|
||||
.map(|key_file| -> miette::Result<_> {
|
||||
let path = key_file.as_path();
|
||||
std::fs::File::open(path)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| {
|
||||
format!("{} is not readable by the current user", path.display())
|
||||
})?;
|
||||
Ok(&*Box::leak(key_file.into_boxed_path()))
|
||||
})
|
||||
.transpose()?,
|
||||
// Load password hash
|
||||
password_hash: password_file
|
||||
.map(|path| -> miette::Result<_> {
|
||||
let pass = std::fs::read_to_string(path.as_path()).into_diagnostic()?;
|
||||
|
||||
let pass: Box<[u8]> = URL_SAFE_NO_PAD
|
||||
.decode(pass.trim().as_bytes())
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("failed to decode password from {}", path.display()))?
|
||||
.into();
|
||||
|
||||
Ok(&*Box::leak(pass))
|
||||
})
|
||||
.transpose()?,
|
||||
ip_file: Box::leak(ip_file.into_boxed_path()),
|
||||
};
|
||||
Ok(pass)
|
||||
})
|
||||
.transpose()
|
||||
.wrap_err("failed to load password hash")?;
|
||||
|
||||
ensure!(
|
||||
state.password_hash.is_some() || insecure,
|
||||
password_hash.is_some() || insecure,
|
||||
"a password must be used"
|
||||
);
|
||||
|
||||
ensure!(
|
||||
state.key_file.is_some() || insecure,
|
||||
"a key file must be used"
|
||||
);
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
|
@ -259,9 +337,17 @@ fn main() -> Result<()> {
|
|||
.wrap_err("failed to start the tokio runtime")?;
|
||||
|
||||
rt.block_on(async {
|
||||
// Load previous IP and update DNS record to point to it (if available)
|
||||
match load_ip(state.ip_file) {
|
||||
Ok(Some(ip)) => match nsupdate(ip, ttl, state.key_file, state.records).await {
|
||||
// Update DNS record with previous IPs (if available)
|
||||
let ips = state.last_ips.lock().await.clone();
|
||||
|
||||
let mut actions = ips
|
||||
.ips()
|
||||
.filter(|ip| config.records.ip_type.valid_for_type(*ip))
|
||||
.flat_map(|ip| nsupdate::Action::from_records(ip, state.ttl, state.records))
|
||||
.peekable();
|
||||
|
||||
if actions.peek().is_some() {
|
||||
match nsupdate::nsupdate(state.key_file, actions).await {
|
||||
Ok(status) => {
|
||||
if !status.success() {
|
||||
error!("nsupdate failed: code {status}");
|
||||
|
@ -274,25 +360,31 @@ fn main() -> Result<()> {
|
|||
.into_diagnostic()
|
||||
.wrap_err("failed to update records with previous IP");
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
info!("No previous IP address set");
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to load last ip address: {err}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Create services
|
||||
let app = Router::new().route("/update", get(update_records));
|
||||
// if a password is provided, validate it
|
||||
let app = if let Some(pass) = password_hash {
|
||||
app.layer(auth::layer(
|
||||
Box::leak(pass),
|
||||
Box::leak(config.password.salt),
|
||||
))
|
||||
} else {
|
||||
app
|
||||
}
|
||||
.layer(config.records.ip_source.into_extension())
|
||||
.with_state(state);
|
||||
|
||||
let config::Server { address } = config.server;
|
||||
|
||||
// Start services
|
||||
let app = Router::new()
|
||||
.route("/update", get(update_records))
|
||||
.layer(ip_source.into_extension())
|
||||
.with_state(state);
|
||||
info!("starting listener on {ip}:{port}");
|
||||
let listener = tokio::net::TcpListener::bind(SocketAddr::new(ip, port))
|
||||
info!("starting listener on {address}");
|
||||
let listener = tokio::net::TcpListener::bind(address)
|
||||
.await
|
||||
.into_diagnostic()?;
|
||||
info!("listening on {ip}:{port}");
|
||||
info!("listening on {address}");
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
|
@ -300,157 +392,318 @@ fn main() -> Result<()> {
|
|||
.await
|
||||
.into_diagnostic()
|
||||
})
|
||||
.wrap_err("failed to run main loop")
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, pass), level = "trace", ret(level = "info"))]
|
||||
/// Serde deserialization decorator to map empty Strings to None,
|
||||
///
|
||||
/// Adapted from: <https://github.com/tokio-rs/axum/blob/main/examples/query-params-with-empty-strings/src/main.rs>
|
||||
fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: std::str::FromStr,
|
||||
T::Err: std::fmt::Display,
|
||||
{
|
||||
use serde::Deserialize;
|
||||
|
||||
let opt = Option::<std::borrow::Cow<'de, str>>::deserialize(de)?;
|
||||
match opt.as_deref() {
|
||||
None | Some("") => Ok(None),
|
||||
Some(s) => s.parse::<T>().map_err(serde::de::Error::custom).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FritzBoxUpdateParams {
|
||||
/// The domain that should be updated
|
||||
#[allow(unused)]
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
domain: Option<String>,
|
||||
/// IPv4 address for the domain
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
ipv4: Option<Ipv4Addr>,
|
||||
/// IPv6 address for the domain
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
ipv6: Option<Ipv6Addr>,
|
||||
/// IPv6 prefix for the home network
|
||||
#[allow(unused)]
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
ipv6prefix: Option<Ipv6Prefix>,
|
||||
/// Whether the networks uses both IPv4 and IPv6
|
||||
#[allow(unused)]
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
dualstack: Option<String>,
|
||||
}
|
||||
|
||||
impl FritzBoxUpdateParams {
|
||||
fn has_data(&self) -> bool {
|
||||
let Self {
|
||||
domain,
|
||||
ipv4,
|
||||
ipv6,
|
||||
ipv6prefix,
|
||||
dualstack,
|
||||
} = self;
|
||||
domain.is_some()
|
||||
| ipv4.is_some()
|
||||
| ipv6.is_some()
|
||||
| ipv6prefix.is_some()
|
||||
| dualstack.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state), level = "trace", ret(level = "info"))]
|
||||
async fn update_records(
|
||||
State(state): State<AppState<'static>>,
|
||||
AuthBasic((username, pass)): AuthBasic,
|
||||
SecureClientIp(ip): SecureClientIp,
|
||||
ClientIp(ip): ClientIp,
|
||||
Query(update_params): Query<FritzBoxUpdateParams>,
|
||||
) -> axum::response::Result<&'static str> {
|
||||
debug!("received update request from {ip}");
|
||||
let Some(pass) = pass else {
|
||||
return Err((StatusCode::UNAUTHORIZED, Json::from("no password provided")).into());
|
||||
};
|
||||
info!("accepted update from {ip}");
|
||||
|
||||
if let Some(stored_pass) = state.password_hash {
|
||||
let password = pass.trim().to_string();
|
||||
let pass_hash = password::hash_identity(&username, &password, state.salt);
|
||||
if pass_hash.as_ref() != stored_pass {
|
||||
warn!("rejected update");
|
||||
trace!(
|
||||
"mismatched hashes:\n{}\n{}",
|
||||
URL_SAFE_NO_PAD.encode(pass_hash.as_ref()),
|
||||
URL_SAFE_NO_PAD.encode(stored_pass),
|
||||
if !update_params.has_data() {
|
||||
if !state.ip_type.valid_for_type(ip) {
|
||||
tracing::warn!(
|
||||
"rejecting update from {ip} as we are running a {} filter",
|
||||
state.ip_type
|
||||
);
|
||||
return Err((StatusCode::UNAUTHORIZED, "invalid identity").into());
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
format!("running in {} mode", state.ip_type),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
return trigger_update(ip, &state).await;
|
||||
}
|
||||
|
||||
// FIXME: mark suspicious updates (where IP doesn't match the update_ip) and reject them based
|
||||
// on policy
|
||||
|
||||
let FritzBoxUpdateParams {
|
||||
domain: _,
|
||||
ipv4,
|
||||
ipv6,
|
||||
ipv6prefix: _,
|
||||
dualstack: _,
|
||||
} = update_params;
|
||||
|
||||
if ipv4.is_none() && ipv6.is_none() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"failed to provide an IP for the update",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if let Some(ip) = ipv4 {
|
||||
let ip = IpAddr::V4(ip);
|
||||
if state.ip_type.valid_for_type(ip) {
|
||||
_ = trigger_update(ip, &state).await?;
|
||||
} else {
|
||||
tracing::warn!("requested update of IPv4 but we are {}", state.ip_type);
|
||||
}
|
||||
}
|
||||
|
||||
info!("accepted update");
|
||||
match nsupdate(ip, state.ttl, state.key_file, state.records).await {
|
||||
if let Some(ip) = ipv6 {
|
||||
let ip = IpAddr::V6(ip);
|
||||
if state.ip_type.valid_for_type(ip) {
|
||||
_ = trigger_update(ip, &state).await?;
|
||||
} else {
|
||||
tracing::warn!("requested update of IPv6 but we are {}", state.ip_type);
|
||||
}
|
||||
}
|
||||
|
||||
Ok("Successfully updated IP of records!\n")
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state), level = "trace", ret(level = "info"))]
|
||||
async fn trigger_update(
|
||||
ip: IpAddr,
|
||||
state: &AppState<'static>,
|
||||
) -> axum::response::Result<&'static str> {
|
||||
let actions = nsupdate::Action::from_records(ip, state.ttl, state.records);
|
||||
|
||||
if actions.len() == 0 {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Nothing to do (e.g. we are ipv4-only but an ipv6 update was requested)",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
match nsupdate::nsupdate(state.key_file, actions).await {
|
||||
Ok(status) if status.success() => {
|
||||
let ips = {
|
||||
// Update state
|
||||
let mut ips = state.last_ips.lock().await;
|
||||
ips.update(ip);
|
||||
ips.clone()
|
||||
};
|
||||
|
||||
let ip_file = state.ip_file;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
info!("updating last ip to {ip}");
|
||||
if let Err(err) = std::fs::write(state.ip_file, format!("{ip}")) {
|
||||
info!("updating last ips to {ips:?}");
|
||||
let data = serde_json::to_vec(&ips).expect("invalid serialization impl");
|
||||
if let Err(err) = std::fs::write(ip_file, data) {
|
||||
error!("Failed to update last IP: {err}");
|
||||
}
|
||||
info!("updated last ip to {ip}");
|
||||
info!("updated last ips to {ips:?}");
|
||||
});
|
||||
Ok("successful update")
|
||||
|
||||
Ok("Successfully updated IP of records!\n")
|
||||
}
|
||||
Ok(status) => {
|
||||
error!("nsupdate failed with code {status}");
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"nsupdate failed, check server logs",
|
||||
"nsupdate failed, check server logs\n",
|
||||
)
|
||||
.into())
|
||||
}
|
||||
Err(error) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to update records: {error}"),
|
||||
format!("failed to update records: {error}\n"),
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", ret(level = "warn"))]
|
||||
async fn nsupdate(
|
||||
ip: IpAddr,
|
||||
ttl: Duration,
|
||||
key_file: Option<&Path>,
|
||||
records: &[&str],
|
||||
) -> 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");
|
||||
stdin
|
||||
.write_all(update_ns_records(ip, ttl, records).as_bytes())
|
||||
.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(ip: IpAddr, ttl: Duration, records: &[&str]) -> String {
|
||||
use std::fmt::Write;
|
||||
let ttl_s: u64 = ttl.as_secs();
|
||||
|
||||
let rec_type = match ip {
|
||||
IpAddr::V4(_) => "A",
|
||||
IpAddr::V6(_) => "AAAA",
|
||||
};
|
||||
let mut cmds = String::from("server 127.0.0.1\n");
|
||||
for &record in records {
|
||||
writeln!(cmds, "update delete {record} {ttl_s} IN {rec_type}").unwrap();
|
||||
writeln!(cmds, "update add {record} {ttl_s} IN {rec_type} {ip}").unwrap();
|
||||
}
|
||||
writeln!(cmds, "send\nquit").unwrap();
|
||||
cmds
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use insta::assert_snapshot;
|
||||
mod parse_query_params {
|
||||
use axum::extract::Query;
|
||||
|
||||
use crate::{update_ns_records, DEFAULT_TTL};
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use super::FritzBoxUpdateParams;
|
||||
|
||||
#[test]
|
||||
#[allow(non_snake_case)]
|
||||
fn expected_update_string_A() {
|
||||
assert_snapshot!(update_ns_records(
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
DEFAULT_TTL,
|
||||
&["example.com.", "example.org.", "example.net."],
|
||||
), @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
|
||||
"###);
|
||||
fn no_params() {
|
||||
let uri = http::Uri::builder()
|
||||
.path_and_query("/update")
|
||||
.build()
|
||||
.unwrap();
|
||||
let query: Query<FritzBoxUpdateParams> = Query::try_from_uri(&uri).unwrap();
|
||||
insta::assert_debug_snapshot!(query, @r#"
|
||||
Query(
|
||||
FritzBoxUpdateParams {
|
||||
domain: None,
|
||||
ipv4: None,
|
||||
ipv6: None,
|
||||
ipv6prefix: None,
|
||||
dualstack: None,
|
||||
},
|
||||
)
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(non_snake_case)]
|
||||
fn expected_update_string_AAAA() {
|
||||
assert_snapshot!(update_ns_records(
|
||||
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
DEFAULT_TTL,
|
||||
&["example.com.", "example.org.", "example.net."],
|
||||
), @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
|
||||
"###);
|
||||
fn ipv4() {
|
||||
let uri = http::Uri::builder()
|
||||
.path_and_query("/update?ipv4=1.2.3.4")
|
||||
.build()
|
||||
.unwrap();
|
||||
let query: Query<FritzBoxUpdateParams> = Query::try_from_uri(&uri).unwrap();
|
||||
insta::assert_debug_snapshot!(query, @r#"
|
||||
Query(
|
||||
FritzBoxUpdateParams {
|
||||
domain: None,
|
||||
ipv4: Some(
|
||||
1.2.3.4,
|
||||
),
|
||||
ipv6: None,
|
||||
ipv6prefix: None,
|
||||
dualstack: None,
|
||||
},
|
||||
)
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6() {
|
||||
let uri = http::Uri::builder()
|
||||
.path_and_query("/update?ipv6=%3A%3A1234")
|
||||
.build()
|
||||
.unwrap();
|
||||
let query: Query<FritzBoxUpdateParams> = Query::try_from_uri(&uri).unwrap();
|
||||
insta::assert_debug_snapshot!(query, @r#"
|
||||
Query(
|
||||
FritzBoxUpdateParams {
|
||||
domain: None,
|
||||
ipv4: None,
|
||||
ipv6: Some(
|
||||
::1234,
|
||||
),
|
||||
ipv6prefix: None,
|
||||
dualstack: None,
|
||||
},
|
||||
)
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_and_ipv6() {
|
||||
let uri = http::Uri::builder()
|
||||
.path_and_query("/update?ipv4=1.2.3.4&ipv6=%3A%3A1234")
|
||||
.build()
|
||||
.unwrap();
|
||||
let query: Query<FritzBoxUpdateParams> = Query::try_from_uri(&uri).unwrap();
|
||||
insta::assert_debug_snapshot!(query, @r#"
|
||||
Query(
|
||||
FritzBoxUpdateParams {
|
||||
domain: None,
|
||||
ipv4: Some(
|
||||
1.2.3.4,
|
||||
),
|
||||
ipv6: Some(
|
||||
::1234,
|
||||
),
|
||||
ipv6prefix: None,
|
||||
dualstack: None,
|
||||
},
|
||||
)
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_and_empty_ipv6() {
|
||||
let uri = http::Uri::builder()
|
||||
.path_and_query("/update?ipv4=1.2.3.4&ipv6=")
|
||||
.build()
|
||||
.unwrap();
|
||||
let query: Query<FritzBoxUpdateParams> = Query::try_from_uri(&uri).unwrap();
|
||||
insta::assert_debug_snapshot!(query, @r#"
|
||||
Query(
|
||||
FritzBoxUpdateParams {
|
||||
domain: None,
|
||||
ipv4: Some(
|
||||
1.2.3.4,
|
||||
),
|
||||
ipv6: None,
|
||||
ipv6prefix: None,
|
||||
dualstack: None,
|
||||
},
|
||||
)
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_ipv4_and_ipv6() {
|
||||
let uri = http::Uri::builder()
|
||||
.path_and_query("/update?ipv4=&ipv6=%3A%3A1234")
|
||||
.build()
|
||||
.unwrap();
|
||||
let query: Query<FritzBoxUpdateParams> = Query::try_from_uri(&uri).unwrap();
|
||||
insta::assert_debug_snapshot!(query, @r#"
|
||||
Query(
|
||||
FritzBoxUpdateParams {
|
||||
domain: None,
|
||||
ipv4: None,
|
||||
ipv6: Some(
|
||||
::1234,
|
||||
),
|
||||
ipv6prefix: None,
|
||||
dualstack: None,
|
||||
},
|
||||
)
|
||||
"#);
|
||||
}
|
||||
}
|
||||
|
|
156
src/nsupdate.rs
Normal file
156
src/nsupdate.rs
Normal file
|
@ -0,0 +1,156 @@
|
|||
use std::{
|
||||
ffi::OsStr,
|
||||
net::IpAddr,
|
||||
path::Path,
|
||||
process::{ExitStatus, Stdio},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
pub enum Action<'a> {
|
||||
// Reassign a domain to a different IP
|
||||
Reassign {
|
||||
domain: &'a str,
|
||||
to: IpAddr,
|
||||
ttl: Duration,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'a> Action<'a> {
|
||||
/// Create a set of [`Action`]s reassigning the domains in `records` to the specified
|
||||
/// [`IpAddr`]
|
||||
pub fn from_records(
|
||||
to: IpAddr,
|
||||
ttl: Duration,
|
||||
records: &'a [&'a str],
|
||||
) -> impl IntoIterator<Item = Self> + std::iter::ExactSizeIterator + 'a {
|
||||
records
|
||||
.iter()
|
||||
.map(move |&domain| Action::Reassign { domain, to, ttl })
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Action<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Action::Reassign { domain, to, ttl } => {
|
||||
let ttl = ttl.as_secs();
|
||||
let kind = match to {
|
||||
IpAddr::V4(_) => "A",
|
||||
IpAddr::V6(_) => "AAAA",
|
||||
};
|
||||
// Delete previous record of type `kind`
|
||||
writeln!(f, "update delete {domain} {ttl} IN {kind}")?;
|
||||
// Add record with new IP
|
||||
writeln!(f, "update add {domain} {ttl} IN {kind} {to}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip(actions), ret(level = "warn"))]
|
||||
pub async fn nsupdate(
|
||||
key_file: Option<&Path>,
|
||||
actions: impl IntoIterator<Item = Action<'_>>,
|
||||
) -> std::io::Result<ExitStatus> {
|
||||
let mut cmd = tokio::process::Command::new("nsupdate");
|
||||
if let Some(key_file) = key_file {
|
||||
cmd.args([OsStr::new("-k"), key_file.as_os_str()]);
|
||||
}
|
||||
debug!("spawning new process");
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.inspect_err(|err| warn!("failed to spawn child: {err}"))?;
|
||||
let mut stdin = child.stdin.take().expect("stdin not present");
|
||||
debug!("sending update request");
|
||||
let mut buf = Vec::new();
|
||||
update_ns_records(&mut buf, actions).unwrap();
|
||||
stdin
|
||||
.write_all(&buf)
|
||||
.await
|
||||
.inspect_err(|err| warn!("failed to write to the stdin of nsupdate: {err}"))?;
|
||||
|
||||
debug!("closing stdin");
|
||||
stdin
|
||||
.shutdown()
|
||||
.await
|
||||
.inspect_err(|err| warn!("failed to close stdin to nsupdate: {err}"))?;
|
||||
debug!("waiting for nsupdate to exit");
|
||||
child
|
||||
.wait()
|
||||
.await
|
||||
.inspect_err(|err| warn!("failed to wait for child: {err}"))
|
||||
}
|
||||
|
||||
fn update_ns_records<'a>(
|
||||
mut buf: impl std::io::Write,
|
||||
actions: impl IntoIterator<Item = Action<'a>>,
|
||||
) -> std::io::Result<()> {
|
||||
writeln!(buf, "server 127.0.0.1")?;
|
||||
for action in actions {
|
||||
write!(buf, "{action}")?;
|
||||
}
|
||||
writeln!(buf, "send")?;
|
||||
writeln!(buf, "quit")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use insta::assert_snapshot;
|
||||
|
||||
use super::{update_ns_records, Action};
|
||||
use crate::DEFAULT_TTL;
|
||||
|
||||
#[test]
|
||||
#[allow(non_snake_case)]
|
||||
fn expected_update_string_A() {
|
||||
let mut buf = Vec::new();
|
||||
let actions = Action::from_records(
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
DEFAULT_TTL,
|
||||
&["example.com.", "example.org.", "example.net."],
|
||||
);
|
||||
update_ns_records(&mut buf, actions).unwrap();
|
||||
|
||||
assert_snapshot!(String::from_utf8(buf).unwrap(), @r###"
|
||||
server 127.0.0.1
|
||||
update delete example.com. 60 IN A
|
||||
update add example.com. 60 IN A 127.0.0.1
|
||||
update delete example.org. 60 IN A
|
||||
update add example.org. 60 IN A 127.0.0.1
|
||||
update delete example.net. 60 IN A
|
||||
update add example.net. 60 IN A 127.0.0.1
|
||||
send
|
||||
quit
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(non_snake_case)]
|
||||
fn expected_update_string_AAAA() {
|
||||
let mut buf = Vec::new();
|
||||
let actions = Action::from_records(
|
||||
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
DEFAULT_TTL,
|
||||
&["example.com.", "example.org.", "example.net."],
|
||||
);
|
||||
update_ns_records(&mut buf, actions).unwrap();
|
||||
|
||||
assert_snapshot!(String::from_utf8(buf).unwrap(), @r###"
|
||||
server 127.0.0.1
|
||||
update delete example.com. 60 IN AAAA
|
||||
update add example.com. 60 IN AAAA ::1
|
||||
update delete example.org. 60 IN AAAA
|
||||
update add example.org. 60 IN AAAA ::1
|
||||
update delete example.net. 60 IN AAAA
|
||||
update add example.net. 60 IN AAAA ::1
|
||||
send
|
||||
quit
|
||||
"###);
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
//! records
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use base64::prelude::*;
|
||||
use miette::{Context, IntoDiagnostic, Result};
|
||||
|
@ -20,28 +20,48 @@ 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, args.password_file.as_deref(), &args.salt)
|
||||
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 data = Vec::with_capacity(username.len() + password.len() + salt.len() + 1);
|
||||
write!(data, "{username}:{password}{salt}").unwrap();
|
||||
ring::digest::digest(&ring::digest::SHA256, &data)
|
||||
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 }: Mkpasswd,
|
||||
password_file: Option<&Path>,
|
||||
salt: &str,
|
||||
Mkpasswd {
|
||||
username,
|
||||
password,
|
||||
salt,
|
||||
password_file,
|
||||
}: Mkpasswd,
|
||||
) -> 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 else {
|
||||
let Some(path) = password_file.as_deref() else {
|
||||
println!("{encoded}");
|
||||
return Ok(());
|
||||
};
|
||||
|
|
136
src/records.rs
136
src/records.rs
|
@ -1,52 +1,9 @@
|
|||
//! Deal with the DNS records
|
||||
|
||||
use std::path::Path;
|
||||
use miette::{ensure, miette, LabeledSpan, Result};
|
||||
|
||||
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(())
|
||||
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<()> {
|
||||
|
@ -156,7 +113,7 @@ fn validate_octet(offset: usize, octet: u8) -> Result<()> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::records::verify;
|
||||
use crate::records::validate_record_str;
|
||||
|
||||
macro_rules! assert_miette_snapshot {
|
||||
($diag:expr) => {{
|
||||
|
@ -180,104 +137,51 @@ mod test {
|
|||
|
||||
#[test]
|
||||
fn valid_records() -> miette::Result<()> {
|
||||
verify(
|
||||
"\
|
||||
example.com.\n\
|
||||
example.org.\n\
|
||||
example.net.\n\
|
||||
subdomain.example.com.\n\
|
||||
",
|
||||
std::path::Path::new("test_records_valid"),
|
||||
)
|
||||
for record in [
|
||||
"example.com.",
|
||||
"example.org.",
|
||||
"example.net.",
|
||||
"subdomain.example.com.",
|
||||
] {
|
||||
validate_record_str(record)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_too_long() {
|
||||
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();
|
||||
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 = verify(
|
||||
"\
|
||||
example.com.\n\
|
||||
example.org.\n\
|
||||
example.net\n\
|
||||
subdomain.example.com.\n\
|
||||
",
|
||||
std::path::Path::new("test_records_invalid"),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err = validate_record_str("example.net").unwrap_err();
|
||||
assert_miette_snapshot!(err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_label() {
|
||||
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();
|
||||
let err = validate_record_str("name..example.org.").unwrap_err();
|
||||
assert_miette_snapshot!(err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_too_long() {
|
||||
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();
|
||||
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 = 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();
|
||||
let err = validate_record_str("name.this-is-not-ascii-ß.example.org.").unwrap_err();
|
||||
assert_miette_snapshot!(err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_octet() {
|
||||
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();
|
||||
let err =
|
||||
validate_record_str("name.this-character:-is-not-allowed.example.org.").unwrap_err();
|
||||
assert_miette_snapshot!(err);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||
|
||||
× empty label
|
||||
╭─[test_records_invalid:2:6]
|
||||
1 │ example.com.
|
||||
2 │ name..example.org.
|
||||
╭────
|
||||
1 │ name..example.org.
|
||||
· ▲
|
||||
· ╰── label
|
||||
3 │ example.net.
|
||||
╰────
|
||||
help: each label should have at least one character
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||
|
||||
× hostname too long (260 octets)
|
||||
╭─[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.
|
||||
╭────
|
||||
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
|
||||
4 │ subdomain.example.com.
|
||||
╰────
|
||||
help: fully qualified domain names can be at most 255 characters long
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
|
||||
|
||||
× invalid octet: '\xc3'
|
||||
╭─[test_records_invalid:2:19]
|
||||
1 │ example.com.
|
||||
2 │ name.this-is-not-aßcii.example.org.
|
||||
· ┬
|
||||
· ╰── octet
|
||||
3 │ example.net.
|
||||
╭────
|
||||
1 │ name.this-is-not-ascii-ß.example.org.
|
||||
· ┬
|
||||
· ╰── octet
|
||||
╰────
|
||||
help: we only accept ascii characters
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
|
||||
|
||||
× invalid octet: ':'
|
||||
╭─[test_records_invalid:2:20]
|
||||
1 │ example.com.
|
||||
2 │ name.this-character:-is-not-allowed.example.org.
|
||||
╭────
|
||||
1 │ 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_-]
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||
|
||||
× label too long (78 octets)
|
||||
╭─[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.
|
||||
╭────
|
||||
1 │ 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
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||
|
||||
× not a fully qualified domain name
|
||||
╭─[test_records_invalid:3:11]
|
||||
2 │ example.org.
|
||||
3 │ example.net
|
||||
╭────
|
||||
1 │ example.net
|
||||
· ┬
|
||||
· ╰── last character
|
||||
4 │ subdomain.example.com.
|
||||
╰────
|
||||
help: hostname should be a fully qualified domain name (end with a '.')
|
||||
|
|
Loading…
Add table
Reference in a new issue