diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 1cf17b4a5..3ae1eec4c 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::{self, Display}; use std::net::SocketAddr; use std::time::{Duration, SystemTime}; @@ -262,14 +262,17 @@ impl fmt::Display for TsigRemoveError { #[derive(Deserialize, Serialize, Debug, Clone)] pub struct TsigListResult { /// The set of TSIG keys known to Cascade plus information about each key. - pub tsig_keys: HashMap, + pub tsig_key_info: HashMap, } /// Information about a single listed TSIG key. #[derive(Deserialize, Serialize, Debug, Clone)] pub struct TsigKeyInfo { /// The set of zones with which this TSIG key is used. - pub zones: Vec, + pub zone_names: HashSet, + + /// The set of policies with which this TSIG key is used. + pub policy_names: HashSet, } //----------- ZoneAdd -------------------------------------------------------- @@ -855,7 +858,7 @@ pub struct ServerPolicyInfo { #[derive(Deserialize, Serialize, Debug, Clone)] pub struct OutboundPolicyInfo { - pub accept_xfr_requests_from: Vec, + pub accept_xfr_from: Vec, pub send_notify_to: Vec, } diff --git a/crates/cli/src/commands/policy.rs b/crates/cli/src/commands/policy.rs index f88d3692a..72854a752 100644 --- a/crates/cli/src/commands/policy.rs +++ b/crates/cli/src/commands/policy.rs @@ -158,8 +158,8 @@ fn print_policy(p: &PolicyInfo) { print_review(&p.signer.review); println!(" server:"); println!(" outbound:"); - println!(" accept XFR requests from:"); - print_nameserver_comms_policy(&p.server.outbound.accept_xfr_requests_from); + println!(" accept XFR from:"); + print_nameserver_comms_policy(&p.server.outbound.accept_xfr_from); println!(" send NOTIFY to:"); print_nameserver_comms_policy(&p.server.outbound.send_notify_to); } diff --git a/crates/cli/src/commands/tsig.rs b/crates/cli/src/commands/tsig.rs index 93b85ac76..02681bac8 100644 --- a/crates/cli/src/commands/tsig.rs +++ b/crates/cli/src/commands/tsig.rs @@ -170,11 +170,18 @@ impl Tsig { TsigCommand::List => { let response: TsigListResult = client.get_json("tsig/").await?; - for (tsig_key_name, key_info) in response.tsig_keys { - // For each TSIG key also list the zones that it is used - // with. - let zones = key_info - .zones + for (tsig_key_name, key_info) in response.tsig_key_info { + // For each TSIG key also list the zones and policies that + // it is used with. + let zone_names = key_info + .zone_names + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + + let policy_names = key_info + .policy_names .iter() .map(ToString::to_string) .collect::>() @@ -182,8 +189,14 @@ impl Tsig { println!("{tsig_key_name}"); print!(" zones: "); - if !zones.is_empty() { - println!("{zones}"); + if !zone_names.is_empty() { + println!("{zone_names}"); + } else { + println!("none"); + } + print!(" policies: "); + if !policy_names.is_empty() { + println!("{policy_names}"); } else { println!("none"); } diff --git a/doc/manual/build/man/cascade-debug.1 b/doc/manual/build/man/cascade-debug.1 index fa379a47b..1629ab12b 100644 --- a/doc/manual/build/man/cascade-debug.1 +++ b/doc/manual/build/man/cascade-debug.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADE-DEBUG" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-DEBUG" "1" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-debug \- Debug / troubleshoot Cascade .SH SYNOPSIS diff --git a/doc/manual/build/man/cascade-health.1 b/doc/manual/build/man/cascade-health.1 index 85821acb5..5dc1d924a 100644 --- a/doc/manual/build/man/cascade-health.1 +++ b/doc/manual/build/man/cascade-health.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADE-HEALTH" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-HEALTH" "1" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-health \- Check the health of Cascade .sp diff --git a/doc/manual/build/man/cascade-hsm.1 b/doc/manual/build/man/cascade-hsm.1 index 62ab56db8..48491c48a 100644 --- a/doc/manual/build/man/cascade-hsm.1 +++ b/doc/manual/build/man/cascade-hsm.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADE-HSM" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-HSM" "1" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-hsm \- Manage HSMs .SH SYNOPSIS diff --git a/doc/manual/build/man/cascade-keyset.1 b/doc/manual/build/man/cascade-keyset.1 index fb0f43cf1..c02403c0b 100644 --- a/doc/manual/build/man/cascade-keyset.1 +++ b/doc/manual/build/man/cascade-keyset.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADE-KEYSET" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-KEYSET" "1" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-keyset \- Execute manual key roll or key removal commands .SH SYNOPSIS diff --git a/doc/manual/build/man/cascade-policy.1 b/doc/manual/build/man/cascade-policy.1 index 0b5ad6a69..59b155730 100644 --- a/doc/manual/build/man/cascade-policy.1 +++ b/doc/manual/build/man/cascade-policy.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADE-POLICY" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-POLICY" "1" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-policy \- Manage policies .SH SYNOPSIS diff --git a/doc/manual/build/man/cascade-status.1 b/doc/manual/build/man/cascade-status.1 index 8de2073be..94b389f77 100644 --- a/doc/manual/build/man/cascade-status.1 +++ b/doc/manual/build/man/cascade-status.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADE-STATUS" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-STATUS" "1" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-status \- Show the status of Cascade .sp diff --git a/doc/manual/build/man/cascade-template.1 b/doc/manual/build/man/cascade-template.1 index 6f96a458c..2f2545568 100644 --- a/doc/manual/build/man/cascade-template.1 +++ b/doc/manual/build/man/cascade-template.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADE-TEMPLATE" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-TEMPLATE" "1" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-template \- Print example config or policy files .SH SYNOPSIS diff --git a/doc/manual/build/man/cascade-tsig.1 b/doc/manual/build/man/cascade-tsig.1 index 4ec29e03f..f32a70b92 100644 --- a/doc/manual/build/man/cascade-tsig.1 +++ b/doc/manual/build/man/cascade-tsig.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADE-TSIG" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-TSIG" "1" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-tsig \- Manage TSIG keys .sp diff --git a/doc/manual/build/man/cascade-zone.1 b/doc/manual/build/man/cascade-zone.1 index e1923b128..731efa184 100644 --- a/doc/manual/build/man/cascade-zone.1 +++ b/doc/manual/build/man/cascade-zone.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADE-ZONE" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-ZONE" "1" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-zone \- Manage zones .SH SYNOPSIS diff --git a/doc/manual/build/man/cascade.1 b/doc/manual/build/man/cascade.1 index 1c68e3994..020c1b56d 100644 --- a/doc/manual/build/man/cascade.1 +++ b/doc/manual/build/man/cascade.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADE" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE" "1" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade \- Cascade CLI .SH SYNOPSIS diff --git a/doc/manual/build/man/cascaded-config.toml.5 b/doc/manual/build/man/cascaded-config.toml.5 index e9d409acf..0dc8d8a27 100644 --- a/doc/manual/build/man/cascaded-config.toml.5 +++ b/doc/manual/build/man/cascaded-config.toml.5 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADED-CONFIG.TOML" "5" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADED-CONFIG.TOML" "5" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascaded-config.toml \- Cascade configuration file .sp diff --git a/doc/manual/build/man/cascaded-policy.toml.5 b/doc/manual/build/man/cascaded-policy.toml.5 index e12339485..95c229197 100644 --- a/doc/manual/build/man/cascaded-policy.toml.5 +++ b/doc/manual/build/man/cascaded-policy.toml.5 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADED-POLICY.TOML" "5" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADED-POLICY.TOML" "5" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascaded-policy.toml \- Cascade policy file format .sp @@ -348,7 +348,7 @@ key roll. Each nameserver must be specified as a string in the form: .INDENT 7.0 .INDENT 3.5 -\fB\(dq:[][^]\(dq\fP +\fB\(dq[:][^]\(dq\fP .UNINDENT .UNINDENT .sp @@ -641,17 +641,29 @@ The \fB[server.outbound]\fP section. .INDENT 0.0 .TP .B send\-notify\-to = [] -The set of nameservers to which NOTIFY messages should be sent +The set of nameservers to which NOTIFY messages should be sent. +.sp +If no nameservers are specified, no NOTIFY messages will be sent. .sp Each nameserver must be specified as a string in the form: .sp -\fI\(dq:[][^]\(dq\fP +\fI\(dq[:][^]\(dq\fP .sp If a TSIG key name is specified, a key by that name must exist in the Cascade TSIG key store and will be used to authenticate communication with the nameserver. +.UNINDENT +.INDENT 0.0 +.TP +.B accept\-xfr\-from = [] +The set of nameservers to accept zone transfer requests from. +.sp +If no nameservers are specified, zone transfer requests will be accepted +from any nameserver. +.sp +Each nameserver must be specified as a string in the form: .sp -If not specified, no NOTIFY messages will be sent. +\fI\(dq[^]\(dq\fP .UNINDENT .SH FILES .INDENT 0.0 diff --git a/doc/manual/build/man/cascaded.1 b/doc/manual/build/man/cascaded.1 index e4a58dd89..1d600961a 100644 --- a/doc/manual/build/man/cascaded.1 +++ b/doc/manual/build/man/cascaded.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADED" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADED" "1" "Apr 30, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascaded \- DNSSEC signer .SH SYNOPSIS diff --git a/doc/manual/source/man/cascaded-policy.toml.rst b/doc/manual/source/man/cascaded-policy.toml.rst index a876c73e9..08b15df30 100644 --- a/doc/manual/source/man/cascaded-policy.toml.rst +++ b/doc/manual/source/man/cascaded-policy.toml.rst @@ -520,17 +520,28 @@ The ``[server.outbound]`` section. .. option:: send-notify-to = [] - The set of nameservers to which NOTIFY messages should be sent + The set of nameservers to which NOTIFY messages should be sent. + + If no nameservers are specified, no NOTIFY messages will be sent. Each nameserver must be specified as a string in the form: - `"[:][^]"` + `"[:][^]"` If a TSIG key name is specified, a key by that name must exist in the Cascade TSIG key store and will be used to authenticate communication with the nameserver. - If not specified, no NOTIFY messages will be sent. +.. option:: accept-xfr-from = [] + + The set of nameservers to accept zone transfer requests from. + + If no nameservers are specified, zone transfer requests will be accepted + from any nameserver. + + Each nameserver must be specified as a string in the form: + + `"[^]"` Files ----- diff --git a/doc/manual/source/nsd.rst b/doc/manual/source/nsd.rst index 8834355e4..78f54092b 100644 --- a/doc/manual/source/nsd.rst +++ b/doc/manual/source/nsd.rst @@ -94,4 +94,31 @@ authenticate with NSD: Using NSD as a secondary to Cascade ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -TODO +The NSD settings relevant here are: + - `allow-notify `_ + - `request-xfr `_ + +To allow NSD to receive and respond to NOTIFY messages from Cascade, and +to instruct NSD to fetch zone updates via zone transfer from Cascade, these +settings need to be added to the zone configuration. + +For example the following configures NSD for an upstream Cascade server +running at 192.168.0.2 listening on the default port 4542: + +.. code-block:: + + zone: + name: example.com + allow-notify: 192.168.0.2 + request-xfr: 192.168.0.2@4542 + +To authenticate zone transfer related messages using TSIG you must first have +added the key to both Cascade and NSD using the same :program:`cascade` :subcmd:`tsig add` +command and ``key:`` block as shown above. + +To instruct Cascade to use TSIG when communicating with a downstream +nameserver such as NSD you must set the required policy settings and reload +the policy. The relevant policy settings in Cascade are: + +- ``server.outbound.send-notify-to`` +- ``server.outbound.accept-xfr-from``` diff --git a/etc/policy.template.toml b/etc/policy.template.toml index 80a434c3a..e476e148d 100644 --- a/etc/policy.template.toml +++ b/etc/policy.template.toml @@ -391,3 +391,17 @@ required = false # # If not specified, no NOTIFY messages will be sent. send-notify-to = [] + +# The set of nameservers to accept zone transfer requests from. +# +# Each nameserver must be specified as a string in the form: +# +# `"[^]"` +# +# If a TSIG key name is specified, a key by that name must exist in the +# Cascade TSIG key store and will be used to authenticate communication with +# the nameserver. +# +# If not specified, zone transfer requests will be accepted from any +# nameserver. +accept-xfr-from = [] diff --git a/integration-tests/scripts/manage-test-environment.sh b/integration-tests/scripts/manage-test-environment.sh index 0d1d833ee..349ce5d69 100755 --- a/integration-tests/scripts/manage-test-environment.sh +++ b/integration-tests/scripts/manage-test-environment.sh @@ -289,6 +289,11 @@ remote-control: control-enable: yes control-interface: "${base_dir}/nsd/nsd.sock" +key: + name: "tsig-key" + algorithm: hmac-sha256 + secret: "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" + pattern: name: secondary zonefile: "%s.secondary-zone" @@ -300,6 +305,27 @@ pattern: zone: name: example.test include-pattern: secondary + +zone: + name: notify-tsig.test + zonefile: "notify-tsig.test.secondary-zone" + allow-notify: 127.0.0.1 tsig-key + request-xfr: AXFR 127.0.0.1@${_cascade_port} NOKEY + provide-xfr: 127.0.0.1 NOKEY + +zone: + name: xfr-tsig.test + zonefile: "xfr-tsig.test.secondary-zone" + allow-notify: 127.0.0.1 NOKEY + request-xfr: AXFR 127.0.0.1@${_cascade_port} tsig-key + provide-xfr: 127.0.0.1 NOKEY + +zone: + name: notify-and-xfr-tsig.test + zonefile: "notify-and-xfr-tsig.test.secondary-zone" + allow-notify: 127.0.0.1 tsig-key + request-xfr: AXFR 127.0.0.1@${_cascade_port} tsig-key + provide-xfr: 127.0.0.1 NOKEY EOF tee "${base_dir}/nsd-primary.conf" <&2 diff --git a/integration-tests/system-tests.yml b/integration-tests/system-tests.yml index be189554d..7e3fb8755 100644 --- a/integration-tests/system-tests.yml +++ b/integration-tests/system-tests.yml @@ -277,3 +277,27 @@ jobs: with: log-level: ${{ inputs.log-level }} key-source: ${{ matrix.key-source }} + + downstream-tsig: + name: Using TSIG with downstream nameservers. + runs-on: ubuntu-latest + strategy: + # max-parallel: 1 + fail-fast: true + matrix: + send-notify-to: [ '', '127.0.0.1:1054', '127.0.0.1:1054^tsig-key' ] + accept-xfr-from: [ '', '127.0.0.1', '127.0.0.1^tsig-key' ] + downstream-expects-tsig: [ 0, 1 ] + downstream-uses-tsig: [ 0, 1 ] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/set-build-profile + with: + build-profile: ${{ inputs.build-profile }} + - uses: ./integration-tests/tests/downstream-tsig + with: + log-level: ${{ inputs.log-level }} + send-notify-to: ${{ matrix.send-notify-to }} + accept-xfr-from: ${{ matrix.accept-xfr-from }} + downstream-expects-tsig: ${{ matrix.downstream-expects-tsig }} + downstream-uses-tsig: ${{ matrix.downstream-uses-tsig }} diff --git a/integration-tests/tests/downstream-tsig/action.yml b/integration-tests/tests/downstream-tsig/action.yml new file mode 100644 index 000000000..006c5a54d --- /dev/null +++ b/integration-tests/tests/downstream-tsig/action.yml @@ -0,0 +1,145 @@ +# Making reusable composite actions documented at +# https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action#creating-a-composite-action-within-the-same-repository +name: 'Using TSIG with downstream nameservers' +description: 'Using TSIG with downstream namservers' +defaults: + # see: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell + run: + shell: bash --noprofile --norc -eo pipefail -x {0} +inputs: + log-level: + description: The level of logging that Cascade should output. + required: false + default: debug + type: choice + options: + - error + - warning + - info + - debug + - trace + send-notify-to: + description: The IP address, port and name of the TSIG key to use when sending NOTIFY messages to downstream nameservers. + required: false + default: '' + type: string + accept-xfr-from: + description: The IP address, port and name of the TSIG key to permit when replying to SOA and XFR requests from downstream nameservers. + required: false + default: '' + type: string + downstream-expects-tsig: + description: Whether or not the downstream secondary NSD should expect the NOTIFY from Cascade to use TSIG. + required: true + type: number + downstream-uses-tsig: + description: Whether or not the downstream secondary NSD should use TSIG with XFR to Cascade. + required: true + type: number +runs: + using: "composite" + steps: + - uses: ./.github/actions/prepare-systest-env + - uses: ./.github/actions/setup-and-start-cascade + with: + log-level: ${{ inputs.log-level }} + + - name: Determine the downstrem secondary zone config to use + id: set-zone-name + run: | + if [[ ${{ inputs.downstream-expects-tsig }} -eq 0 && ${{ inputs.downstream-uses-tsig }} -eq 0 ]]; then + ZONE_NAME="example.test." + elif [[ ${{ inputs.downstream-expects-tsig }} -eq 0 && ${{ inputs.downstream-uses-tsig }} -eq 1 ]]; then + ZONE_NAME="xfr-tsig.test." + elif [[ ${{ inputs.downstream-expects-tsig }} -eq 1 && ${{ inputs.downstream-uses-tsig }} -eq 0 ]]; then + ZONE_NAME="notify-tsig.test." + else + ZONE_NAME="notify-and-xfr-tsig.test." + fi + echo "ZONE_NAME=${ZONE_NAME}" >> "$GITHUB_OUTPUT" + + - name: Determine if we expect this test to succeed or fail + id: set-expected-outcome + run: | + if [[ "${{ inputs.send-notify-to }}" != *"^"* && ${{ inputs.downstream-expects-tsig }} -eq 0 ]]; then + SUCCESS=1 + elif [[ "${{ inputs.send-notify-to }}" != *"^"* && ${{ inputs.downstream-expects-tsig }} -eq 1 ]]; then + SUCCESS=0 + elif [[ "${{ inputs.send-notify-to }}" == *"^"* && ${{ inputs.downstream-expects-tsig }} -eq 0 ]]; then + SUCCESS=0 + else + SUCCESS=1 + fi + echo "SUCCESS=${SUCCESS}" >> "$GITHUB_OUTPUT" + + - name: Make a zonefile to load zone ${{ steps.set-zone-name.outputs.ZONE_NAME }} + run: | + cat >test.zone <<'EOF' + $TTL 5 ; use a very short TTL for sped up keyset rolls + ${{ steps.set-zone-name.outputs.ZONE_NAME }} IN SOA ns1.${{ steps.set-zone-name.outputs.ZONE_NAME }} mail.${{ steps.set-zone-name.outputs.ZONE_NAME }} ( + 1 ; serial + 60 ; refresh (60 seconds) + 60 ; retry (60 seconds) + 3600 ; expire (1 hour) + 5 ; minimum (5 seconds) + ) + @ NS ${{ steps.set-zone-name.outputs.ZONE_NAME }} + @ NS ns1.${{ steps.set-zone-name.outputs.ZONE_NAME }} + @ A 127.0.0.1 + ns1 A 127.0.0.1 + + www A 169.254.1.1 + mail MX 10 ${{ steps.set-zone-name.outputs.ZONE_NAME }} + text TXT "Hello World!" + EOF + + - name: Add a custom policy + run: | + POLICY_DIR=$(integration-tests/scripts/get-default-path.sh policy-dir) + cascade template policy | grep -Ev '(send-notify-to|accept-xfr-from)' > "${POLICY_DIR}/restricted.toml" + if [ "${{ inputs.accept-xfr-from }}" != "" ]; then + echo 'accept-xfr-from = ["${{ inputs.accept-xfr-from }}"]' >> "${POLICY_DIR}/restricted.toml" + fi + if [ "${{ inputs.send-notify-to }}" != "" ]; then + echo 'send-notify-to = ["${{ inputs.send-notify-to }}"]' >> "${POLICY_DIR}/restricted.toml" + fi + if [[ "${{ inputs.accept-xfr-from}}" == *"^tsig-key"* || "${{ inputs.send-notify-to }}" == *"^tsig-key"* ]]; then + cascade tsig add tsig-key hmac-sha256 "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" + fi + cascade policy reload + + - name: Add zone ${{ steps.set-zone-name.outputs.ZONE_NAME }} using the custom policy + run: | + cascade zone add --policy restricted --source $PWD/test.zone ${{ steps.set-zone-name.outputs.ZONE_NAME }} + + - name: Wait for zone ${{ steps.set-zone-name.outputs.ZONE_NAME }} to be signed and published + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone status ${{ steps.set-zone-name.outputs.ZONE_NAME }}| grep -q "Published zone available"; do + if (($(date +%s) > (start + timeout))); then + cascade zone status ${{ steps.set-zone-name.outputs.ZONE_NAME }} + echo "timeout: zone status did not report published zone available" >&2 + exit 1 + fi + sleep 1 + done + + - name: Verify that the ${{ steps.set-zone-name.outputs.ZONE_NAME }} zone is being served + run: | + dig @127.0.0.1 -p 4542 ${{ steps.set-zone-name.outputs.ZONE_NAME }} SOA &> soa-query.log + + - name: Tell NSD that zone ${{ steps.set-zone-name.outputs.ZONE_NAME }} is now available + # Only necessary if NOTIFY is not setup for cascade + if: ${{ inputs.send-notify-to == '' }} + run: | + ./integration-tests/scripts/manage-test-environment.sh control nsd-secondary transfer ${{ steps.set-zone-name.outputs.ZONE_NAME }} + + - name: The ${{ steps.set-zone-name.outputs.ZONE_NAME }} zone should now be available via the secondary + if: ${{ steps.set-expected-outcome.output.SUCCESS }} + run: | + dig @127.0.0.1 -p 1054 text.${{ steps.set-zone-name.outputs.ZONE_NAME }} TXT | grep 'Hello World' + + - name: Print log files on any failure in this job + uses: ./.github/actions/print-logfiles + if: failure() diff --git a/integration-tests/tests/upstream-tsig/action.yml b/integration-tests/tests/upstream-tsig/action.yml index d30fa8b2e..4f1155bd5 100644 --- a/integration-tests/tests/upstream-tsig/action.yml +++ b/integration-tests/tests/upstream-tsig/action.yml @@ -85,6 +85,34 @@ runs: sleep 1 done + - name: Verify that tsig list shows the added key correctly. + run: | + cascade tsig list &> tsig-list.log + cat >/tmp/tsig-list-expected.out <<'EOF' + tsig-key + zones: example-tsig.test + policies: none + EOF + diff -b -u tsig-list.log /tmp/tsig-list-expected.out + + - name: Verify that attempting to remove the TSIG key fails. + run: | + cascade tsig remove tsig-key &> tsig-remove.log && exit 1 + grep -F 'the TSIG key cannot be removed as it is in use' tsig-remove.log + + - name: Remove the zone + run: | + cascade zone remove example-tsig.test + + - name: Remove the TSIG key + run: | + cascade tsig remove tsig-key + + - name: Verify that tsig list no longer shows TSIG key. + run: | + NUM_LINES=$(cascade tsig list | wc -l) + [[ ${NUM_LINES} -eq 0 ]] || exit 1 + - name: Print log files on any failure in this job uses: ./.github/actions/print-logfiles if: failure() diff --git a/src/policy/file/v1.rs b/src/policy/file/v1.rs index fceb15148..71efcbd89 100644 --- a/src/policy/file/v1.rs +++ b/src/policy/file/v1.rs @@ -882,7 +882,7 @@ pub struct OutboundSpec { /// /// If empty, any nameserver may request XFR from us. #[serde(default = "empty_list")] - pub accept_xfr_requests_from: Vec, + pub accept_xfr_from: Vec, /// The set of nameservers to which NOTIFY messages should be sent. /// @@ -903,8 +903,8 @@ impl OutboundSpec { /// Parse from this specification. pub fn parse(self) -> OutboundPolicy { OutboundPolicy { - accept_xfr_requests_from: self - .accept_xfr_requests_from + accept_xfr_from: self + .accept_xfr_from .into_iter() .map(|v| v.parse()) .collect(), @@ -915,8 +915,8 @@ impl OutboundSpec { /// Build into this specification. pub fn build(policy: &OutboundPolicy) -> Self { Self { - accept_xfr_requests_from: policy - .accept_xfr_requests_from + accept_xfr_from: policy + .accept_xfr_from .iter() .map(NameserverCommsSpec::build) .collect(), @@ -949,22 +949,22 @@ pub enum NameserverCommsSpec { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct ComplexNameserverCommsSpec { - /// The address to send NOTIFYs to. + /// The address to send NOTIFYs to or allow XFRs from. pub addr: SocketAddr, /// An optional TSIG key to sign and authenticate messages with. - tsig_key_name: Option, + pub tsig_key_name: Option, } /// Policy for communicating with another namesever. #[derive(Clone, Debug, DeserializeFromStr, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct SimpleNameserverCommsSpec { - /// The address to send NOTIFYs to. + /// The address to send NOTIFYs to or allow XFRs from. pub addr: SocketAddr, /// An optional TSIG key to sign and authenticate messages with. - tsig_key_name: Option, + pub tsig_key_name: Option, } //--- Conversion @@ -1007,12 +1007,12 @@ impl ComplexNameserverCommsSpec { } } -/// Parse`[:]` +/// Parse`[:][^]` impl FromStr for SimpleNameserverCommsSpec { type Err = String; fn from_str(s: &str) -> Result { - let (tsig_key_name, s) = s.split_once('^').unwrap_or(("", s)); + let (s, tsig_key_name) = s.split_once('^').unwrap_or((s, "")); let tsig_key_name = if !tsig_key_name.is_empty() { Some( diff --git a/src/policy/mod.rs b/src/policy/mod.rs index 06ee7f663..fa480e161 100644 --- a/src/policy/mod.rs +++ b/src/policy/mod.rs @@ -217,7 +217,7 @@ fn check_policy(policy: &PolicyVersion, tsig_store: &TsigStore) -> Result<(), Po .key_manager .publication_nameservers .iter() - .chain(policy.server.outbound.accept_xfr_requests_from.iter()) + .chain(policy.server.outbound.accept_xfr_from.iter()) .chain(policy.server.outbound.send_notify_to.iter()) .filter_map(|ns| ns.tsig_key_name.as_ref()); @@ -455,7 +455,7 @@ pub struct OutboundPolicy { /// The set of nameservers from which SOA and XFR requests may be received. /// /// If empty, any nameserver may request XFR from us. - pub accept_xfr_requests_from: Vec, + pub accept_xfr_from: Vec, /// The set of nameservers to which NOTIFY messages should be sent. /// @@ -465,33 +465,23 @@ pub struct OutboundPolicy { pub send_notify_to: Vec, } -//----------- InboundPolicy --------------------------------------------------- - -/// Policy for restricting from whom data may be received. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct InboundPolicy { - /// The set of nameservers to which SOA and XFR requests should be sent. - /// - /// If empty, the nameserver from which the zone was received will be - /// contacted. - pub send_xfr_requests_to: Vec, - - /// The set of nameservers from which may NOTIFY messages may be received. - /// - /// If empty, the nameserver from which the zone was received will be - /// allowed to send us NOTIFY messages. - pub accept_notify_messages_from: Vec, -} - //----------- NameserverCommsPolicy ------------------------------------------- /// Policy for communicating with another namesever. +/// +/// This type serves a dual purpose: +/// - For outbound communication it specifies the address and port of the +/// nameserver to contact, and optionally a TSIG key that should be used +/// to sign outbound requests. When used for this purpose the address and +/// port are mandatory. +/// - For inbound communication this type is intended to support the access +/// control use case, acting as a white list entry. When used for this +/// purpose typically a port is not specified as the sending port that +/// will be used by the client cannot be known in advance. #[derive(Clone, Debug, PartialEq, Eq)] pub struct NameserverCommsPolicy { /// The address to send to/receive from. /// - /// For sending the port MUST NOT be zero. - /// /// TODO: Support IP prefixes? pub addr: SocketAddr, diff --git a/src/server/service.rs b/src/server/service.rs index 8aaedf0a2..89a618421 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -128,6 +128,13 @@ mod compat { return Box::pin(std::future::ready(error(old_request.message(), rcode))); }; + if !is_permitted(zone, &old_request) { + return Box::pin(std::future::ready(error( + old_request.message(), + Rcode::REFUSED, + ))); + } + match zone_request.kind { ZoneRequestKind::Soa => Box::pin({ let viewer = zone.viewer.clone(); @@ -152,6 +159,41 @@ mod compat { } } + fn is_permitted( + zone: &ServedZone, + old_request: &Request, Option>>, + ) -> bool { + let zone_state = zone.handle.state.lock().unwrap(); + + if let Some(acls) = zone_state + .policy + .as_ref() + .map(|p| &p.server.outbound.accept_xfr_from) + { + // If at least one ACL was specified, enforce it. + if !acls.is_empty() { + let wanted_tsig_key_name = old_request.metadata().as_ref().map(|key| key.name()); + + for acl in acls { + // Does the client address match the allowed address? + if acl.addr.ip() == old_request.client_addr().ip() { + // Is the request signed with the right TSIG key? + if acl.tsig_key_name.as_ref() == wanted_tsig_key_name { + // Allow the request. + return true; + } + } + } + + // No ACL matched, reject the request. + return false; + } + } + + // No ACL defined, accept the request. + true + } + fn soa(request: &Message>, viewer: &V) -> ResponseStream { if viewer.is_empty() { // The zone is known to exist, but we don't have any data for it. diff --git a/src/tsig/mod.rs b/src/tsig/mod.rs index ace134997..1f4c2fc90 100644 --- a/src/tsig/mod.rs +++ b/src/tsig/mod.rs @@ -307,7 +307,7 @@ pub fn remove_key(center: &Arc
, name: &tsig::KeyName) -> Result<(), Remo .any(|ns| ns.tsig_key_name.as_ref() == Some(name)) || p.server .outbound - .accept_xfr_requests_from + .accept_xfr_from .iter() .any(|acl| acl.tsig_key_name.as_ref() == Some(name)) || p.server diff --git a/src/units/http_server.rs b/src/units/http_server.rs index 3324bcc3f..07998dbfc 100644 --- a/src/units/http_server.rs +++ b/src/units/http_server.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::hash_map::Entry; use std::future::IntoFuture; use std::sync::Arc; use std::sync::atomic::Ordering::Relaxed; @@ -958,8 +960,8 @@ impl HttpServer { let p_outbound = &p.latest.server.outbound; let server = ServerPolicyInfo { outbound: OutboundPolicyInfo { - accept_xfr_requests_from: p_outbound - .accept_xfr_requests_from + accept_xfr_from: p_outbound + .accept_xfr_from .iter() .map(|v| NameserverCommsPolicyInfo { addr: v.addr }) .collect(), @@ -1171,41 +1173,86 @@ impl HttpServer { State(http_server_state): State>, Path(tsig_key_name): Path, ) -> Json> { - let result = tsig::remove_key(&http_server_state.center, &tsig_key_name) + let res = tsig::remove_key(&http_server_state.center, &tsig_key_name) + .map(|_| TsigRemoveResult) .map_err(|e| match e { RemoveError::NotFound => TsigRemoveError::NotFound, RemoveError::Used => TsigRemoveError::InUse, - }) - // Map Ok value that we don't use. - .map(|_| TsigRemoveResult); - if result.is_err() { - return Json(result); - } - - Json(Ok(TsigRemoveResult)) + }); + Json(res) } async fn tsig_key_list(State(http_state): State>) -> Json { + let mut tsig_key_info = HashMap::new(); + let state = http_state.center.state.lock().unwrap(); - let mut tsig_keys = HashMap::new(); - for tsig_key_name in state.tsig_store.map.keys() { - let zones = state - .zones + + // Get the set of TSIG keys and related zones from the TSIG key store. + for (tsig_key_name, key) in state.tsig_store.map.iter() { + let zone_names = key.zones.iter().map(|item| item.0.name.clone()).collect(); + tsig_key_info.insert( + tsig_key_name.clone(), + TsigKeyInfo { + zone_names, + policy_names: HashSet::new(), + }, + ); + } + + // Note: We don't loop over all of the zones checking for TSIG keys + // referenced in the upstream source configuration because those + // relationships should be captured in the set of zones associated + // with TSIG keys in the TSIG key store that we accessed in the loop + // above. + + // Find the set of policies that reference TSIG keys as these + // relationships are not tracked by the TSIG key store. Ignore + // policies that are in the process of being deleted. + let current_policies = state.policies.iter().filter(|(_, p)| !p.mid_deletion); + + // For each policy, collect any TSIG key names from the various policy + // fields that may refer to TSIG keys, then update the TSIG key info + // result set we are building to note the relationship between the + // TSIG key and the policies that refer to it. + for (policy_name, policy) in current_policies { + let mut tsig_key_names = policy + .latest + .key_manager + .publication_nameservers .iter() - .filter_map(|zone| { - let zone_state = zone.0.state.lock().unwrap(); - match &zone_state.loader.source { - loader::Source::Server { - tsig_key: Some(tsig_key), - .. - } if tsig_key.name() == tsig_key_name => Some(zone.0.name.clone()), - _ => None, + .chain(policy.latest.server.outbound.accept_xfr_from.iter()) + .chain(policy.latest.server.outbound.send_notify_to.iter()) + .filter_map(|acl| acl.tsig_key_name.as_ref()) + .peekable(); + + // If we found at least one reference to a TSIG key in this policy + // update the map of TSIG key info results to pass back to the + // caller. + if tsig_key_names.peek().is_some() { + // For each found TSIG key: + for tsig_key_name in tsig_key_names { + match tsig_key_info.entry(tsig_key_name.clone()) { + Entry::Occupied(mut e) => { + // Info about this TSIG key already exists in the + // results, update the result info to note the + // relationship to this policy. + e.get_mut().policy_names.insert(policy_name.to_string()); + } + Entry::Vacant(e) => { + // Info about this TSIG key does not yet exist in + // the results, update the result info to note the + // relationship to this policy. + e.insert(TsigKeyInfo { + zone_names: HashSet::new(), + policy_names: [policy_name.to_string()].into(), + }); + } } - }) - .collect::>(); - tsig_keys.insert(tsig_key_name.clone(), TsigKeyInfo { zones }); + } + } } - Json(TsigListResult { tsig_keys }) + + Json(TsigListResult { tsig_key_info }) } } diff --git a/src/units/zone_server.rs b/src/units/zone_server.rs index bfea55287..2b105bade 100644 --- a/src/units/zone_server.rs +++ b/src/units/zone_server.rs @@ -1,6 +1,6 @@ use std::future::Future; use std::marker::Sync; -use std::net::{IpAddr, SocketAddr}; +use std::net::IpAddr; use std::pin::Pin; use std::process::Stdio; use std::sync::Arc; @@ -23,6 +23,7 @@ use domain::net::server::middleware::tsig::TsigMiddlewareSvc; use domain::net::server::service::Service; use domain::net::server::stream::{self, StreamServer}; use domain::tsig::{Algorithm, KeyStore}; +use domain::zonetree::StoredName; use tracing::{debug, error, info, trace, warn}; use crate::api::{ @@ -33,6 +34,7 @@ use crate::config::SocketConfig; use crate::daemon::SocketProvider; use crate::manager::Terminated; use crate::manager::record_zone_event; +use crate::policy::NameserverCommsPolicy; use crate::server::{LoadedReviewServer, PublicationServer, SignedReviewServer}; use crate::util::AbortOnDrop; use crate::zone::{ @@ -237,19 +239,17 @@ impl ZoneServer { "[{unit_name}]: Found {} NOTIFY targets", policy.server.outbound.send_notify_to.len() ); + trace!( + "NOTIFY targets: {:?}", + policy.server.outbound.send_notify_to + ); if !policy.server.outbound.send_notify_to.is_empty() { let addrs = policy .server .outbound .send_notify_to .iter() - .filter_map(|s| { - if s.addr.port() != 0 { - Some(s.addr) - } else { - None - } - }); + .filter(|s| s.addr.port() != 0); send_notify_to_addrs(zone_name.clone(), addrs, center); } @@ -728,10 +728,10 @@ impl Notifiable for LoaderNotifier { } } -pub fn send_notify_to_addrs( - apex_name: Name, - notify_set: impl Iterator, - _center: &Arc
, +pub fn send_notify_to_addrs<'a>( + apex_name: StoredName, + notify_set: impl Iterator, + center: &Arc
, ) { let mut dgram_config = domain::net::client::dgram::Config::new(); dgram_config.set_max_parallel(1); @@ -744,32 +744,19 @@ pub fn send_notify_to_addrs( let mut msg = msg.question(); msg.push((apex_name, Rtype::SOA)).unwrap(); - for nameserver_addr in notify_set { + for nameserver in notify_set { let dgram_config = dgram_config.clone(); let req = RequestMessage::new(msg.clone()).unwrap(); - // let tsig_key = zone_info - // .config - // .send_notify_to - // .dst(&nameserver_addr) - // .and_then(|cfg| cfg.tsig_key.as_ref()) - // .and_then(|(name, alg)| key_store.get_key(name, *alg)); - // - // if let Some(key) = tsig_key.as_ref() { - // debug!( - // "Found TSIG key '{}' (algorith {}) for NOTIFY to {nameserver_addr}", - // key.as_ref().name(), - // key.as_ref().algorithm() - // ); - // } - + let nameserver = nameserver.clone(); + let center = center.clone(); tokio::spawn(async move { // TODO: Use the connection factory here. - let udp_connect = UdpConnect::new(nameserver_addr); + let udp_connect = UdpConnect::new(nameserver.addr); let client = Connection::with_config(udp_connect, dgram_config.clone()); - trace!("Sending NOTIFY to nameserver {nameserver_addr}"); - let span = tracing::trace_span!("auth", addr = %nameserver_addr); + trace!("Sending NOTIFY to nameserver {nameserver}"); + let span = tracing::trace_span!("auth", addr = %nameserver); let _guard = span.enter(); // https://datatracker.ietf.org/doc/html/rfc1996 @@ -781,16 +768,31 @@ pub fn send_notify_to_addrs( // // TODO: We have no retry queue at the moment. Do we need one? - // let res = if let Some(key) = tsig_key { - // let client = net::client::tsig::Connection::new(key.clone(), client); - // client.send_request(req.clone()).get_response().await - // } else { - // client.send_request(req.clone()).get_response().await - // }; - let res = client.send_request(req.clone()).get_response().await; + let tsig_key = { + let state = center.state.lock().unwrap(); + nameserver + .tsig_key_name + .as_ref() + .and_then(|tsig_key_name| state.tsig_store.get(tsig_key_name)) + .map(|key| key.inner.clone()) + }; + + if let Some(key) = &tsig_key { + debug!( + "Found TSIG key '{}' (algorithm {}) for NOTIFY to {nameserver}", + key.name(), + key.algorithm() + ); + } + let res = if let Some(key) = tsig_key { + let client = domain::net::client::tsig::Connection::new(key.clone(), client); + client.send_request(req.clone()).get_response().await + } else { + client.send_request(req.clone()).get_response().await + }; if let Err(err) = res { - warn!("Unable to send NOTIFY to nameserver {nameserver_addr}: {err}"); + warn!("Unable to send NOTIFY to nameserver {nameserver}: {err}"); } }); }