feat(dns): 建立统一清单并接管 Samba 静态记录
This commit is contained in:
@@ -6,11 +6,13 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'infrastructure/**/ansible/**'
|
- 'infrastructure/**/ansible/**'
|
||||||
|
- 'infrastructure/dns/**'
|
||||||
- '.ansible-lint'
|
- '.ansible-lint'
|
||||||
- '.gitea/workflows/ansible.yml'
|
- '.gitea/workflows/ansible.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'infrastructure/**/ansible/**'
|
- 'infrastructure/**/ansible/**'
|
||||||
|
- 'infrastructure/dns/**'
|
||||||
- '.ansible-lint'
|
- '.ansible-lint'
|
||||||
- '.gitea/workflows/ansible.yml'
|
- '.gitea/workflows/ansible.yml'
|
||||||
|
|
||||||
@@ -55,3 +57,10 @@ jobs:
|
|||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
done
|
done
|
||||||
exit $rc
|
exit $rc
|
||||||
|
|
||||||
|
- name: Test repository-local Ansible collection
|
||||||
|
working-directory: infrastructure/samba-ad/ansible/collections/ansible_collections/ddupan/homelab
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
ansible-test sanity --venv --requirements --python 3.12 --color no
|
||||||
|
ansible-test units --venv --requirements --python 3.12 --color no
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# DNS 声明与权威边界
|
||||||
|
|
||||||
|
`records.yml` 是 homelab DNS 的唯一声明清单,但不是 DNS 服务本身。不同视图仍由最适合
|
||||||
|
它们的后端提供:
|
||||||
|
|
||||||
|
| 视图 | 权威或递归服务 | 配置方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 公网 `ddupan.top` | Cloudflare | Terraform;尚待完整导入已有记录 |
|
||||||
|
| AD `ad.ddupan.top` | Samba internal DNS | `samba_dns_record` Ansible module |
|
||||||
|
| LAN split horizon | Blocky | 尚待从 inventory 渲染或校验 |
|
||||||
|
| Kubernetes Pod split horizon | CoreDNS | 尚待从 inventory 渲染或校验 |
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
- Samba module 只管理 `homelab_dns.samba.records` 明确列出的 RRset,不遍历或清理 zone。
|
||||||
|
- `_ldap`、`_kerberos`、域控制器 locator 等由 Samba 自动维护的记录不进入 inventory。
|
||||||
|
- 一个受管 RRset 默认使用 `exact: true`:同名同类型的额外值会被删除,但其他名称和类型
|
||||||
|
不受影响。
|
||||||
|
- DHCP 切换不属于本阶段。Blocky 仍未成为 LAN 客户端的正式 resolver。
|
||||||
|
|
||||||
|
## 分阶段接管
|
||||||
|
|
||||||
|
1. 用 Samba module 接管现有静态 A RRset,首次 check mode 应为零变更。
|
||||||
|
2. 将 Cloudflare 已有 tunnel DNS 记录导入 Terraform state。
|
||||||
|
3. 让 Blocky 与 CoreDNS 从 `split_horizon.records` 生成配置或执行 CI 一致性检查。
|
||||||
|
4. 验证公网、LAN、Pod、AD 四个视图后,再单独修改 DHCP。
|
||||||
|
|
||||||
|
当前 inventory 已明确暴露一个既有差异:`obj.ddupan.top` 在 Blocky 中存在,但 CoreDNS
|
||||||
|
尚无对应覆盖。本阶段不会偷偷修复它;后续在两个 resolver 同时接管时统一修复。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
# Homelab DNS desired state. This file is the canonical inventory; individual
|
||||||
|
# backends consume only the views they own.
|
||||||
|
homelab_dns:
|
||||||
|
samba:
|
||||||
|
# Samba remains authoritative for the AD zone. Only these explicitly listed
|
||||||
|
# RRsets are reconciled; Samba-generated AD/Kerberos records are untouched.
|
||||||
|
records:
|
||||||
|
- { zone: ad.ddupan.top, name: bao, type: A, values: [192.168.10.8] }
|
||||||
|
- { zone: ad.ddupan.top, name: pve1, type: A, values: [192.168.10.4] }
|
||||||
|
- { zone: ad.ddupan.top, name: pve2, type: A, values: [192.168.10.7] }
|
||||||
|
- { zone: ad.ddupan.top, name: pve3, type: A, values: [192.168.10.9] }
|
||||||
|
- { zone: ad.ddupan.top, name: retrolab, type: A, values: [10.60.0.10] }
|
||||||
|
- { zone: ad.ddupan.top, name: netbox, type: A, values: [192.168.10.127] }
|
||||||
|
- { zone: ad.ddupan.top, name: s3, type: A, values: [192.168.10.127] }
|
||||||
|
|
||||||
|
split_horizon:
|
||||||
|
# LAN and pod resolvers should eventually render the same set from here.
|
||||||
|
# Adoption of Blocky/CoreDNS is deliberately a separate change.
|
||||||
|
records:
|
||||||
|
- { name: git.ddupan.top, type: A, values: [192.168.10.127] }
|
||||||
|
- { name: auth.ddupan.top, type: A, values: [192.168.10.127] }
|
||||||
|
- { name: obj.ddupan.top, type: A, values: [192.168.10.127] }
|
||||||
|
|
||||||
|
public:
|
||||||
|
# Names expected at Cloudflare. Terraform adoption is a separate change;
|
||||||
|
# listing them here makes the current ownership gap explicit.
|
||||||
|
tunnel_names:
|
||||||
|
- auth.ddupan.top
|
||||||
|
- git.ddupan.top
|
||||||
|
- obj.ddupan.top
|
||||||
|
- e5renew.ddupan.top
|
||||||
@@ -54,6 +54,7 @@ second run is a no-op there and only reconciles config drift.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `roles/dc_vm/` | creates the DC VM locally via libvirt + cloud-init |
|
| `roles/dc_vm/` | creates the DC VM locally via libvirt + cloud-init |
|
||||||
| `roles/samba_ad_dc/` | provisions the DC (§1) — idempotent |
|
| `roles/samba_ad_dc/` | provisions the DC (§1) — idempotent |
|
||||||
|
| `ansible/collections/ansible_collections/ddupan/homelab/` | repository-local collection containing the Samba DNS module and its `ansible-test` tests |
|
||||||
| `roles/samba_ad_dc/tasks/verify.yml` | smoke tests (`--tags verify`) |
|
| `roles/samba_ad_dc/tasks/verify.yml` | smoke tests (`--tags verify`) |
|
||||||
| `roles/samba_ad_dc/tasks/legacy.yml` | opt-in retro-client protocols (§ Retro clients) |
|
| `roles/samba_ad_dc/tasks/legacy.yml` | opt-in retro-client protocols (§ Retro clients) |
|
||||||
| `roles/windows_vm/` | unattended-installs the Windows Server 2025 admin box (§2.0) |
|
| `roles/windows_vm/` | unattended-installs the Windows Server 2025 admin box (§2.0) |
|
||||||
@@ -62,6 +63,9 @@ second run is a no-op there and only reconciles config drift.
|
|||||||
| `group_vars/all/vault.yml` | admin passwords (gitignored; encrypt with ansible-vault) |
|
| `group_vars/all/vault.yml` | admin passwords (gitignored; encrypt with ansible-vault) |
|
||||||
| `inventory/hosts.yml` | DC + Windows hosts |
|
| `inventory/hosts.yml` | DC + Windows hosts |
|
||||||
|
|
||||||
|
Static DNS records are declared once in `../../dns/records.yml`. The role manages
|
||||||
|
only those RRsets and deliberately leaves Samba-generated AD locator records alone.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. Decisions — fill these in before touching anything
|
## 0. Decisions — fill these in before touching anything
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[defaults]
|
[defaults]
|
||||||
inventory = inventory/hosts.yml
|
inventory = inventory/hosts.yml
|
||||||
roles_path = roles
|
roles_path = roles
|
||||||
|
collections_paths = collections
|
||||||
host_key_checking = False
|
host_key_checking = False
|
||||||
callback_result_format = yaml
|
callback_result_format = yaml
|
||||||
nocows = True
|
nocows = True
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
tests/output/
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
# ddupan.homelab
|
||||||
|
|
||||||
|
Repository-local Ansible collection for homelab infrastructure modules. It is consumed
|
||||||
|
directly through the `collections_paths` setting and is not published.
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
namespace: ddupan
|
||||||
|
name: homelab
|
||||||
|
version: 0.1.0
|
||||||
|
readme: README.md
|
||||||
|
authors:
|
||||||
|
- panxiao81
|
||||||
|
description: Ansible plugins used to manage the ddupan.top homelab.
|
||||||
|
license:
|
||||||
|
- GPL-3.0-or-later
|
||||||
|
tags:
|
||||||
|
- infrastructure
|
||||||
|
- dns
|
||||||
|
repository: https://git.ddupan.top/panxiao81/homelab-infra
|
||||||
|
build_ignore:
|
||||||
|
- .git
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
|
||||||
|
# Copyright: (c) 2026, homelab-infra contributors
|
||||||
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
|
||||||
|
DOCUMENTATION = r"""
|
||||||
|
---
|
||||||
|
module: samba_dns_record
|
||||||
|
short_description: Reconcile one Samba internal DNS RRset
|
||||||
|
author:
|
||||||
|
- Pan Xiao (@panxiao81)
|
||||||
|
description:
|
||||||
|
- Reconciles only the named RRset and never deletes an undeclared zone or name.
|
||||||
|
- Runs C(samba-tool dns) on a Samba AD domain controller using its machine account.
|
||||||
|
options:
|
||||||
|
server:
|
||||||
|
description: DNS server accepted by C(samba-tool dns).
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
zone:
|
||||||
|
description: Authoritative DNS zone.
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
name:
|
||||||
|
description: Record owner relative to the zone.
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
type:
|
||||||
|
description: DNS record type.
|
||||||
|
type: str
|
||||||
|
choices: [A, AAAA, CNAME, PTR, TXT]
|
||||||
|
required: true
|
||||||
|
values:
|
||||||
|
description: Complete desired value set for this owner and type.
|
||||||
|
type: list
|
||||||
|
elements: str
|
||||||
|
default: []
|
||||||
|
state:
|
||||||
|
description: Whether the desired RRset is present or absent.
|
||||||
|
type: str
|
||||||
|
choices: [present, absent]
|
||||||
|
default: present
|
||||||
|
exact:
|
||||||
|
description: Remove live values not present in C(values).
|
||||||
|
type: bool
|
||||||
|
default: true
|
||||||
|
"""
|
||||||
|
|
||||||
|
EXAMPLES = r"""
|
||||||
|
- name: Reconcile an A RRset
|
||||||
|
samba_dns_record:
|
||||||
|
server: dc1.ad.ddupan.top
|
||||||
|
zone: ad.ddupan.top
|
||||||
|
name: pve1
|
||||||
|
type: A
|
||||||
|
values: [192.168.10.4]
|
||||||
|
exact: true
|
||||||
|
"""
|
||||||
|
|
||||||
|
RETURN = r"""
|
||||||
|
before:
|
||||||
|
description: Values observed before reconciliation.
|
||||||
|
type: list
|
||||||
|
returned: always
|
||||||
|
after:
|
||||||
|
description: Values expected after reconciliation.
|
||||||
|
type: list
|
||||||
|
returned: always
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
|
|
||||||
|
|
||||||
|
ABSENT_ERRORS = (
|
||||||
|
"WERR_DNS_ERROR_NAME_DOES_NOT_EXIST",
|
||||||
|
"WERR_DNS_ERROR_RECORD_DOES_NOT_EXIST",
|
||||||
|
"WERR_DNS_ERROR_NXDOMAIN",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_value(record_type, value):
|
||||||
|
value = value.strip()
|
||||||
|
if record_type in ("CNAME", "PTR"):
|
||||||
|
return value.rstrip(".").lower()
|
||||||
|
if record_type == "TXT" and len(value) >= 2 and value[0] == value[-1] == '"':
|
||||||
|
return value[1:-1]
|
||||||
|
return value.lower() if record_type == "AAAA" else value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_query(stdout, record_type):
|
||||||
|
values = []
|
||||||
|
prefix = re.compile(r"^\s*%s:\s*(.*?)\s*(?:\([^)]*\))?\s*$" % re.escape(record_type))
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
match = prefix.match(line)
|
||||||
|
if match:
|
||||||
|
values.append(normalize_value(record_type, match.group(1)))
|
||||||
|
return sorted(set(values))
|
||||||
|
|
||||||
|
|
||||||
|
def run(module, args, check_rc=True):
|
||||||
|
command = [module.get_bin_path("samba-tool", required=True), "dns"] + args + ["-P"]
|
||||||
|
return module.run_command(command, check_rc=check_rc)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
module = AnsibleModule(
|
||||||
|
argument_spec=dict(
|
||||||
|
server=dict(type="str", required=True),
|
||||||
|
zone=dict(type="str", required=True),
|
||||||
|
name=dict(type="str", required=True),
|
||||||
|
type=dict(type="str", required=True, choices=["A", "AAAA", "CNAME", "PTR", "TXT"]),
|
||||||
|
values=dict(type="list", elements="str", default=[]),
|
||||||
|
state=dict(type="str", choices=["present", "absent"], default="present"),
|
||||||
|
exact=dict(type="bool", default=True),
|
||||||
|
),
|
||||||
|
supports_check_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
p = module.params
|
||||||
|
desired = sorted(set(normalize_value(p["type"], value) for value in p["values"]))
|
||||||
|
if p["state"] == "present" and not desired:
|
||||||
|
module.fail_json(msg="values must not be empty when state=present")
|
||||||
|
|
||||||
|
rc, stdout, stderr = run(
|
||||||
|
module,
|
||||||
|
["query", p["server"], p["zone"], p["name"], p["type"]],
|
||||||
|
check_rc=False,
|
||||||
|
)
|
||||||
|
if rc == 0:
|
||||||
|
current = parse_query(stdout, p["type"])
|
||||||
|
elif any(marker in stdout + stderr for marker in ABSENT_ERRORS):
|
||||||
|
current = []
|
||||||
|
else:
|
||||||
|
module.fail_json(msg="samba-tool dns query failed", rc=rc, stdout=stdout, stderr=stderr)
|
||||||
|
|
||||||
|
target = desired if p["state"] == "present" else []
|
||||||
|
additions = sorted(set(target) - set(current))
|
||||||
|
removals = sorted(set(current) - set(target)) if p["exact"] or p["state"] == "absent" else []
|
||||||
|
changed = bool(additions or removals)
|
||||||
|
|
||||||
|
if changed and not module.check_mode:
|
||||||
|
for value in removals:
|
||||||
|
run(module, ["delete", p["server"], p["zone"], p["name"], p["type"], value])
|
||||||
|
for value in additions:
|
||||||
|
run(module, ["add", p["server"], p["zone"], p["name"], p["type"], value])
|
||||||
|
|
||||||
|
after = sorted((set(current) - set(removals)) | set(additions))
|
||||||
|
module.exit_json(
|
||||||
|
changed=changed,
|
||||||
|
before=current,
|
||||||
|
after=after,
|
||||||
|
diff={"before": {p["type"]: current}, "after": {p["type"]: after}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from ansible_collections.ddupan.homelab.plugins.modules import samba_dns_record
|
||||||
|
|
||||||
|
|
||||||
|
class ParseQueryTests(unittest.TestCase):
|
||||||
|
def test_parses_a_records_and_ignores_other_types(self):
|
||||||
|
output = """ Name=host, Records=3, Children=0
|
||||||
|
A: 192.168.10.4 (flags=f0, serial=1, ttl=900)
|
||||||
|
A: 192.168.10.7 (flags=f0, serial=2, ttl=900)
|
||||||
|
TXT: ignored (flags=f0, serial=3, ttl=900)
|
||||||
|
"""
|
||||||
|
self.assertEqual(
|
||||||
|
samba_dns_record.parse_query(output, "A"),
|
||||||
|
["192.168.10.4", "192.168.10.7"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_normalizes_dns_targets(self):
|
||||||
|
output = " CNAME: Gateway.AD.DDUPAN.TOP. (flags=f0, serial=1, ttl=900)\n"
|
||||||
|
self.assertEqual(
|
||||||
|
samba_dns_record.parse_query(output, "CNAME"),
|
||||||
|
["gateway.ad.ddupan.top"],
|
||||||
|
)
|
||||||
@@ -10,29 +10,8 @@ samba_ad_dc_ip: "192.168.10.5"
|
|||||||
samba_ad_dns_forwarder: "192.168.10.1"
|
samba_ad_dns_forwarder: "192.168.10.1"
|
||||||
samba_ad_reverse_zone: "10.168.192.in-addr.arpa" # reverse of 192.168.10.0/24
|
samba_ad_reverse_zone: "10.168.192.in-addr.arpa" # reverse of 192.168.10.0/24
|
||||||
|
|
||||||
# Extra A records for non-domain hosts published in the AD DNS zone.
|
# Static records now live in ../../dns/records.yml and are reconciled as complete
|
||||||
samba_ad_extra_a_records:
|
# RRsets by the local samba_dns_record module.
|
||||||
- { name: "bao", ip: "192.168.10.8" } # OpenBao (../openbao), not domain-joined
|
|
||||||
# Proxmox cluster nodes (../proxmox). Not domain-joined; they authenticate
|
|
||||||
# USERS against this DC rather than being members themselves.
|
|
||||||
- { name: "pve1", ip: "192.168.10.4" }
|
|
||||||
- { name: "pve2", ip: "192.168.10.7" }
|
|
||||||
- { name: "pve3", ip: "192.168.10.9" }
|
|
||||||
# Lab VMs on the SDN VNets (routed via the VyOS router, see ../../proxmox).
|
|
||||||
# These are NOT on 192.168.10.0/24, so they have no PTR in the existing
|
|
||||||
# reverse zone — forward resolution only unless a 0.60.10.in-addr.arpa zone
|
|
||||||
# is added later.
|
|
||||||
- { name: "retrolab", ip: "10.60.0.10" }
|
|
||||||
# k3s services exposed on the LAN through the Envoy gateway (../../../platform/envoy-gateway;
|
|
||||||
# Contour was retired 2026-07-25). They all point at the k3s node, which is where
|
|
||||||
# Envoy's LoadBalancer lands; the gateway routes by Host header and serves the
|
|
||||||
# *.ad.ddupan.top wildcard cert.
|
|
||||||
# Adding another such service = one more line here + an HTTPRoute, nothing else.
|
|
||||||
- { name: "netbox", ip: "192.168.10.127" } # NetBox (../../../apps/netbox)
|
|
||||||
# SeaweedFS S3. Exists so Terraform state does NOT ride the Cloudflare tunnel:
|
|
||||||
# obj.ddupan.top works, but it hairpins through the WAN, and on 2026-07-28 that
|
|
||||||
# path was blackholed for hours by a dead VPN tunnel. State must stay on the LAN.
|
|
||||||
- { name: "s3", ip: "192.168.10.127" } # SeaweedFS S3 (../../../apps/seaweedfs)
|
|
||||||
|
|
||||||
# Support legacy clients (Win9x/NT4/2000/XP)? INSECURE — see README "Retro clients".
|
# Support legacy clients (Win9x/NT4/2000/XP)? INSECURE — see README "Retro clients".
|
||||||
samba_ad_legacy_clients: false
|
samba_ad_legacy_clients: false
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
hosts: samba_dc
|
hosts: samba_dc
|
||||||
become: true
|
become: true
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
|
vars_files:
|
||||||
|
- ../../dns/records.yml
|
||||||
roles:
|
roles:
|
||||||
- role: samba_ad_dc
|
- role: samba_ad_dc
|
||||||
post_tasks:
|
post_tasks:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
- name: Reconcile explicitly managed AD DNS RRsets
|
||||||
|
ddupan.homelab.samba_dns_record:
|
||||||
|
server: "{{ samba_ad_dc_ip }}"
|
||||||
|
zone: "{{ item['zone'] }}"
|
||||||
|
name: "{{ item['name'] }}"
|
||||||
|
type: "{{ item['type'] }}"
|
||||||
|
values: "{{ item['values'] }}"
|
||||||
|
state: present
|
||||||
|
exact: true
|
||||||
|
loop: "{{ homelab_dns.samba.records }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item['name'] }}.{{ item['zone'] }} {{ item['type'] }}"
|
||||||
|
tags: [dns]
|
||||||
@@ -198,20 +198,11 @@
|
|||||||
- "'already exists' not in (kms_srv.stderr | default('')) + (kms_srv.stdout | default(''))"
|
- "'already exists' not in (kms_srv.stderr | default('')) + (kms_srv.stdout | default(''))"
|
||||||
no_log: true
|
no_log: true
|
||||||
|
|
||||||
# --- Extra A records for non-domain hosts (e.g. OpenBao) -----------------------
|
# --- Directory objects and explicitly managed DNS records ----------------------
|
||||||
- name: Service accounts and RBAC groups
|
- name: Service accounts and RBAC groups
|
||||||
ansible.builtin.import_tasks: directory_objects.yml
|
ansible.builtin.import_tasks: directory_objects.yml
|
||||||
tags: [directory, accounts]
|
tags: [directory, accounts]
|
||||||
|
|
||||||
- name: Register extra A records in the AD DNS zone
|
- name: Reconcile static AD DNS records
|
||||||
ansible.builtin.command:
|
ansible.builtin.import_tasks: dns_records.yml
|
||||||
cmd: >-
|
|
||||||
samba-tool dns add {{ samba_ad_dc_ip }} {{ samba_ad_realm | lower }}
|
|
||||||
{{ item.name }} A {{ item.ip }} -P
|
|
||||||
loop: "{{ samba_ad_extra_a_records }}"
|
|
||||||
register: extra_a
|
|
||||||
changed_when: "'Record added successfully' in (extra_a.stdout | default(''))"
|
|
||||||
failed_when:
|
|
||||||
- extra_a.rc != 0
|
|
||||||
- "'already exists' not in (extra_a.stderr | default('')) + (extra_a.stdout | default(''))"
|
|
||||||
tags: [dns]
|
tags: [dns]
|
||||||
|
|||||||
Reference in New Issue
Block a user